SwiftUI: #State variable never get updated from #Published - ios

I'm trying to trigger an alert when is an error in the model but it never get updated to show the alert:
Here is my implementation in the view:
struct ContentView: View {
#ObservedObject var viewModel: ViewModel
#State var showAlert = false
init() {
viewModel = ViewModel()
showAlert = viewModel.showAlert
}
var body: some View {
NavigationView {
Text("Hello, world!")
.padding()
}
.alert(isPresented: $showAlert) {
Alert(title: Text("This works"),
message: Text("Hello"),
dismissButton: .default(Text("got it"))
)}
}
}
Here is my models:
class ViewModel: ObservableObject {
#Published var showAlert = false
var cancellables = Set<AnyCancellable>()
init() {
DoSomething.shared.showAlert.sink { _ in
print("got new Value")
} receiveValue: {[weak self] value in
print("value")
self?.showAlert = value
}.store(in: &cancellables)
}
}
class DoSomething {
let showAlert = PassthroughSubject<Bool, Never>()
static let shared = DoSomething()
private init() {
checkToShowAlert()
}
func checkToShowAlert() {
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
print("change value")
self?.showAlert.send(true)
}
}
}
Any of you knows why the showAlert variable it never gets updated?
I'll really appreciate your help

In your current code, you're setting ContentView's showAlert to the ViewModel's showAlert at that point in time:
init() {
viewModel = ViewModel()
showAlert = viewModel.showAlert //<-- assignment at the time of init
}
Meaning, it's false at the time of assignment. Because it's just a Bool getting assigned to another Bool, there's no mechanism to keep it updated if ViewModel's showAlert changes.
The simplest solution is to get rid of your #State variable and observe the #Published property directly:
struct ContentView: View {
#ObservedObject var viewModel: ViewModel = ViewModel()
var body: some View {
NavigationView {
Text("Hello, world!")
.padding()
}
.alert(isPresented: $viewModel.showAlert) {
Alert(title: Text("This works"),
message: Text("Hello"),
dismissButton: .default(Text("got it"))
)}
}
}

Best remove the view model object, we don't need those in SwiftUI because the View struct holds the view data and the #State and #Binding property wrappers make the struct behave like an object.
Also, I don't think you need Combine for what you are trying to do because you aren't combining anything using combineLatest etc, but when we do use it in SwiftUI we don't use sink or store, instead we assign the end of the pipeline to an #Published.
Example:
struct ContentView: View {
#State var isPresented = false
var body: some View {
NavigationView {
VStack{
Text("Hello, world!")
.padding()
Button("Show Alert") {
showAlert()
}
}
}
.alert(isPresented: $isPresented) {
Alert(title:Text("This works"),
message: Text("Hello"),
dismissButton: .default(Text("got it"))
)}
}
func showAlert() {
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
isPresented = true
}
}
}

Related

SwiftUI - Showing and hiding a custom alert

I am creating a custom alert reusable modifier which will have different number of buttons depending upon the style I need. Anyways in my sample code below, I have removed as much extra code as I can to keep it small and simple.
With the code below, I am able to show the alert, but clicking on "Click Me" button does not dismiss the alert. I feel something is wrong with the way I am setting "isPresented" binding variable, but not able to figure out what. Am new to SwiftUI.
Custom Alert Modifier:
struct CustomAlertView: ViewModifier {
var isPresented: Binding<Bool>
init(isPresented: Binding<Bool>) {
self.isPresented = isPresented
}
func body(content: Content) -> some View {
content.overlay(alertContent())
}
#ViewBuilder
private func alertContent() -> some View {
GeometryReader { geometry in
if self.isPresented {
// Do something
// Setting isPresented to false is not doing anything when button is clicked.
Button(action: { self.isPresented.wrappedValue = false }) {
Text("Click Me")
}
}
}
}
}
func customAlert(isPresented: Binding<Bool>) -> some View {
return modifier(CustomAlertView(isPresented: isPresented))
}
View and view model code:
struct MyView: View {
#ObservedObject var viewModel: CustomViewModel = CustomViewModel()
var body: some View {
VStack {
switch viewModel.state {
case .idle:
Color.clear.onAppear(perform: { viewModel.doSomething() })
case .showNewView:
// Navigate
}
}.onAppear() {
viewModel.state = .idle
}.customAlert(isPresented: .constant(viewModel.showAlert))
}
}
#MainActor
class CustomViewModel: ObservableObject {
enum State {
case idle
case showNewView
}
#Published var state = State.idle
#Published var showAlert = false
func doSomething() {
if failure {
self.showAlert = true
} else {
self.state = .showNewView
}
}
}
What am I doing wrong or why am I not able to dismiss the alert?
Thanks for looking!
First of all, you want #Binding var isPresented: Bool inside the CustomAlertView, and you assign it in init as self._isPresented = isPresented:
struct CustomAlertView: ViewModifier {
#Binding var isPresented: Bool // <-- like this
init(isPresented: Binding<Bool>) {
self._isPresented = isPresented // <-- and like this
}
//...
And second, you definitely don't want to set isPresented to a constant in .customAlert(isPresented: .constant(viewModel.showAlert)). Instead, you need to pass the binding variable:
VStack {
// ...
}
.customAlert(isPresented: $viewModel.showAlert) // <-- bind it here

How to use if/ForEach in a SwiftUI View to show IAP modal?

I'm trying to display a Subscribe Now modal view instantly after the app starts to encourage users to subscribe to the Pro In-App Purchase, so I used the .onAppear modifier, and it works fine only if I want to show the modal every time the app starts.
struct ContentView: View {
#State private var selection: String? = nil
#State private var showModal = false
#ObservedObject var storeManager: StoreManager
var body: some View {
NavigationView {
VStack {
// Contents Here
}
}
.onAppear {
self.selection = "Pro"
self.showModal.toggle()
}
.sheet(isPresented: $showModal) {
if self.selection == "Pro" {
Pro(showModal: self.$showModal, storeManager: self.storeManager)
.onAppear(perform: {
SKPaymentQueue.default().add(storeManager)
})
}
}
}
}
Now, the problem begins when I want to display the modal only to those who have not subscribed yet to the Pro IAP, so I modified .onAppear to:
.onAppear {
ForEach(storeManager.myProducts, id: \.self) { product in
VStack {
if !UserDefaults.standard.bool(forKey: product.productIdentifier) {
self.selection = "Pro"
self.showModal.toggle()
}
}
}
}
But, the if and ForEach seems not to work smoothly with structs and views. How should I use them in my case?
Update:
Based on the answers, I have changed the loop inside .onAppear to make the code conforms to SwiftUI requirements:
.onAppear {
storeManager.myProducts.forEach { product in
// Alternatively, I can use (for in) loop:
// for product in storeManager.myProducts {
if !UserDefaults.standard.bool(forKey: product.productIdentifier) {
self.selection = "Pro"
self.showModal.toggle()
}
}
}
Now, errors have gone away but the modal is not displayed on startup.
I discovered that the problem is, storeManager.myProducts is not loaded in .onAppear modifier, while it's loaded correctly when I put the same loop in a button instead of .onAppear, any ideas? Why does onAppear doesn't load the IAP? Where should I put the code to make the modal run when the view loaded?
Update 2:
Here is a Minimal Reproducible Example:
App:
import SwiftUI
#main
struct Reprod_SOFApp: App {
#StateObject var storeManager = StoreManager()
let productIDs = ["xxxxxxxxxxxxxxxxxxxxx"]
var body: some Scene {
DocumentGroup(newDocument: Reprod_SOFDocument()) { file in
ContentView(document: file.$document, storeManager: storeManager)
.onAppear() {
storeManager.getProducts(productIDs: productIDs)
}
}
}
}
ContentView:
import SwiftUI
import StoreKit
struct ContentView: View {
#Binding var document: Reprod_SOFDocument
#State private var selection: String? = nil
#State private var showModal = false
#ObservedObject var storeManager: StoreManager
var test = ["t"]
var body: some View {
TextEditor(text: $document.text)
.onAppear {
// storeManager.myProducts.forEach(id: \.self) { product in
// Alternatively, I can use (for in) loop:
for i in test {
if !i.isEmpty {
self.selection = "Pro"
self.showModal.toggle()
}
}
}
.sheet(isPresented: $showModal) {
if self.selection == "Pro" {
Modal(showModal: self.$showModal, storeManager: self.storeManager)
.onAppear(perform: {
SKPaymentQueue.default().add(storeManager)
})
}
}
}
}
Modal:
import SwiftUI
import StoreKit
struct Modal: View {
#Binding var showModal: Bool
#ObservedObject var storeManager: StoreManager
var body: some View {
Text("hello world")
}
}
StoreManager:
import Foundation
import StoreKit
class StoreManager: NSObject, ObservableObject, SKProductsRequestDelegate, SKPaymentTransactionObserver {
#Published var myProducts = [SKProduct]()
var request: SKProductsRequest!
#Published var transactionState: SKPaymentTransactionState?
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .purchasing:
transactionState = .purchasing
case .purchased:
UserDefaults.standard.setValue(true, forKey: transaction.payment.productIdentifier)
queue.finishTransaction(transaction)
transactionState = .purchased
case .restored:
UserDefaults.standard.setValue(true, forKey: transaction.payment.productIdentifier)
queue.finishTransaction(transaction)
transactionState = .restored
case .failed, .deferred:
print("Payment Queue Error: \(String(describing: transaction.error))")
queue.finishTransaction(transaction)
transactionState = .failed
default:
queue.finishTransaction(transaction)
}
}
}
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
print("Did receive response")
if !response.products.isEmpty {
for fetchedProduct in response.products {
DispatchQueue.main.async {
self.myProducts.append(fetchedProduct)
}
}
}
for invalidIdentifier in response.invalidProductIdentifiers {
print("Invalid identifiers found: \(invalidIdentifier)")
}
}
func getProducts(productIDs: [String]) {
print("Start requesting products ...")
let request = SKProductsRequest(productIdentifiers: Set(productIDs))
request.delegate = self
request.start()
}
func request(_ request: SKRequest, didFailWithError error: Error) {
print("Request did fail: \(error)")
}
func purchaseProduct(product: SKProduct) {
if SKPaymentQueue.canMakePayments() {
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
} else {
print("User can't make payment.")
}
}
func restoreProducts() {
print("Restoring products ...")
SKPaymentQueue.default().restoreCompletedTransactions()
}
}
Here is a link to Minimal Reproducible Example
Instead of using .onAppear modifier to display the modal, you can change the initial values of selection and showModal:
#State private var selection: String? = "Pro"
#State private var showModal = !UserDefaults.standard.bool(forKey: "xxxxxxxxxxxxxxxxxxxxx") ? true : false
// Write your product identifier instead of "xxxxxxxxxxxxxxxxxxxxx"
This way, modal view will be shown instantly after the content view loads.
Note: For showModal, I've applied a conditional if instead of simply true, since you said you want to show the modal only to those who have not subscribed yet to the Pro IAP.
I would recommend to separate logic into a viewmodel, and you only need to manage one identified object to show your pro modal.
struct ContentView: View {
#ObservedObject var viewModel: ContentViewModel
var body: some View {
Text("Hello")
.onAppear(perform: viewModel.fetchStatus)
.sheet(item: $viewModel.carrier) { carrier in
ModalView(storeManager: carrier.storeManager)
}
}
}
class ContentViewModel: ObservableObject {
#Published var carrier: ModalObject?
private let storeManager: StoreManager
func fetchStatus() {
// do something asynchronous like
storeManager.fetchProducts() { [self] products in
if !products.contains(proProducts) {
self.carrier = ModalObject(storeManager: self.storeManager)
}
}
}
}
struct ModalObject: Identifiable {
var id = UUID()
let storeManager: StoreManager
}
I just wrote without compiling, please check with your xcode.
Your .onAppear{} should be Swift code instead SwiftUI (ForEach, VStack). VStack are view structs.

Running code when SwiftUI Toggle Changes Value

The general structure of my code is that I have a UIKit app in which I am trying to embed a swiftui view. So I have a file called SettingsViewController which is as follows:
class SettingsViewController: UIViewController {
...
var items: [(SettingsView.Setting)] = ...
var actionsForItems: [() -> Void = []
#State var isOn: Bool = false
override func viewDidLoad() {
super.viewDidLoad()
actionsForItems = ...
...
addSettingCell(isOn: isOn)
let childView = UIHostingController(rootView: SettingsView(settings: items, actionsForSettings: actionsForItems))
addChild(childView)
childView.view.frame = container.bounds
container.addSubview(childView.view)
childView.didMove(toParent: self)
}
...
func addCell(isOn: Bool) {
items.insert((settingName, $isOn as AnyObject) as SettingsView.Setting)
actionsForItems.insert({
self.onSwitchValueChanged(isOn: isOn) //defined later
})
}
}
which creates a view called Settings View which is structured as follows:
struct SettingsView: View {
typealias Setting = (key: String, value: AnyObject?)
var settings: [Setting]
var actions: [() -> Void]
var body: some View {
List(settings.indices) { index in
...
SettingsWithSwitchView(setting: settings[index], action: actions[index], isOn: setting.value as Binding<Bool>)
}
Spacer()
}
}
and SettingsWithSwitchView is as follows:
struct SettingsWithSwitchView: View {
var setting: SettingsView.Setting
var action: () -> Void
#Binding var isOn: Bool {
willSet {
print("willSet: newValue =\(newValue) oldValue =\(isOn)")
}
didSet {
print("didSet: oldValue=\(oldValue) newValue=\(isOn)")
action()
}
}
var body: some View {
HStack {
Text(setting.key)
.foregroundColor(Color("GrayText"))
.font(Font.custom("OpenSans", size: 15))
Spacer()
Toggle(isOn: $isOn) {}
}
}
}
I read in another post here on Stack Overflow that calling didSet on the isOn property should be the way to accomplish this, but I need to call onSwitchValueChanged when the Toggle value is updated, but my current setup does not work. I would really appreciate some help to figure out how to do this. I can update with some other information if necessary.
The thing that ended up working for me was creating a ViewModel which was also an ObservableObject and then setting the action for the toggle inside of .onTapGesture

How to get a view to update automatically after period of inactivity?

I am trying to add a timeout feature to my SwiftUI app. The view should be updated when timeout is reached. I have found code on a different thread, which works for the timeout part, but I cannot get the view to update.
I am using a static property in the UIApplication extension to toggle the timeout flag. Looks like the view is not notified when this static property changes. What is the correct way to do this?
Clarification added:
#workingdog has proposed an answer below. This does not quite work, because in the actual app, there is not just one view, but multiple views that the user can navigate between. So, I am looking for a global timer that gets reset by any touch action whatever the current view is.
In the sample code, the global timer works, but the view does not take notice when the static var UIApplication.timeout is changed to true.
How can I get the view to update? Is there something more appropriate for this purpose than a static var? Or maybe the timer should not be in the UIApplication extension to begin with?
Here is my code:
import SwiftUI
#main
struct TimeoutApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onAppear(perform: UIApplication.shared.addTapGestureRecognizer)
}
}
}
extension UIApplication {
private static var timerToDetectInactivity: Timer?
static var timeout = false
func addTapGestureRecognizer() {
guard let window = windows.first else { return }
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tapped))
tapGesture.requiresExclusiveTouchType = false
tapGesture.cancelsTouchesInView = false
tapGesture.delegate = self
window.addGestureRecognizer(tapGesture)
}
private func resetTimer() {
let showScreenSaverInSeconds: TimeInterval = 5
if let timerToDetectInactivity = UIApplication.timerToDetectInactivity {
timerToDetectInactivity.invalidate()
}
UIApplication.timerToDetectInactivity = Timer.scheduledTimer(
timeInterval: showScreenSaverInSeconds,
target: self,
selector: #selector(timeout),
userInfo: nil,
repeats: false
)
}
#objc func timeout() {
print("Timeout")
Self.timeout = true
}
#objc func tapped(_ sender: UITapGestureRecognizer) {
if !Self.timeout {
print("Tapped")
self.resetTimer()
}
}
}
extension UIApplication: UIGestureRecognizerDelegate {
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Text(UIApplication.timeout ? "TimeOut" : "Hello World!")
.padding()
Button("Print to Console") {
print(UIApplication.timeout ? "Timeout reached, why is view not updated?" : "Hello World!")
}
}
}
}
to update the view when timeout is reached, you could do something like this:
import SwiftUI
#main
struct TimeoutApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#State var thingToUpdate = "tap the screen to rest the timer"
#State var timeRemaining = 5
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack (spacing: 30) {
Text("\(thingToUpdate)")
Text("\(timeRemaining)")
.onReceive(timer) { _ in
if timeRemaining > 0 {
timeRemaining -= 1
} else {
thingToUpdate = "refreshed, tap the screen to rest the timer"
}
}
}
.frame(minWidth: 0, idealWidth: .infinity, maxWidth: .infinity,
minHeight: 0, idealHeight: .infinity, maxHeight: .infinity,
alignment: .center)
.contentShape(Rectangle())
.onTapGesture {
thingToUpdate = "tap the screen to rest the timer"
timeRemaining = 5
}
}
}
You can do this like this - >
Create a subclass of "UIApplication" (use separate files like
MYApplication.swift).
Use the below code in the file.
Now method "idle_timer_exceeded" will get called once the user stops
touching the screen.
Use notification to update the UI
import UIKit
import Foundation
private let g_secs = 5.0 // Set desired time
class MYApplication: UIApplication
{
var idle_timer : dispatch_cancelable_closure?
override init()
{
super.init()
reset_idle_timer()
}
override func sendEvent( event: UIEvent )
{
super.sendEvent( event )
if let all_touches = event.allTouches() {
if ( all_touches.count > 0 ) {
let phase = (all_touches.anyObject() as UITouch).phase
if phase == UITouchPhase.Began {
reset_idle_timer()
}
}
}
}
private func reset_idle_timer()
{
cancel_delay( idle_timer )
idle_timer = delay( g_secs ) { self.idle_timer_exceeded() }
}
func idle_timer_exceeded()
{
// You can broadcast notification here and use an observer to trigger the UI update function
reset_idle_timer()
}
}
You can view the source for this post here.
Here is my code for a "global" timer using the "Environment". Although it works for me, it seems to be a lot of
work just to do something simple. This leads me to believe there must be
a better way to do this. Anyhow, there maybe some ideas you can recycle here.
import SwiftUI
#main
struct TestApp: App {
#StateObject var globalTimer = MyGlobalTimer()
var body: some Scene {
WindowGroup {
ContentView().environment(\.globalTimerKey, globalTimer)
}
}
}
struct MyGlobalTimerKey: EnvironmentKey {
static let defaultValue = MyGlobalTimer()
}
extension EnvironmentValues {
var globalTimerKey: MyGlobalTimer {
get { return self[MyGlobalTimerKey] }
set { self[MyGlobalTimerKey] = newValue }
}
}
class MyGlobalTimer: ObservableObject {
#Published var timeRemaining: Int = 5
var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
func reset(_ newValue: Int = 5) {
timeRemaining = newValue
}
}
struct ContentView: View {
#Environment(\.globalTimerKey) var globalTimer
#State var refresh = true
var body: some View {
GlobalTimerView { // <-- this puts the content into a tapable view
// the content
VStack {
Text(String(globalTimer.timeRemaining))
.accentColor(refresh ? .black :.black) // <-- trick to refresh the view
.onReceive(globalTimer.timer) { _ in
if globalTimer.timeRemaining > 0 {
globalTimer.timeRemaining -= 1
refresh.toggle() // <-- trick to refresh the view
print("----> time remaining: \(globalTimer.timeRemaining)")
} else {
// do something at every time step after the countdown
print("----> do something ")
}
}
}
}
}
}
struct GlobalTimerView<Content: View>: View {
#Environment(\.globalTimerKey) var globalTimer
let content: Content
init(#ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
ZStack {
content
Color(white: 1.0, opacity: 0.001)
.frame(minWidth: 0, idealWidth: .infinity, maxWidth: .infinity,
minHeight: 0, idealHeight: .infinity, maxHeight: .infinity,
alignment: .center)
.contentShape(Rectangle())
.onTapGesture {
globalTimer.reset()
}
}.onAppear {
globalTimer.reset()
}
}
}

Inform view that alert OK button is clicked in swiftUI

I have created a ViewModifier to show alert with 2 buttons. How to inform my ContentView that OK button is clicked, so that I can perform button action?
Sample code: ShowAlert is my custom ViewModifier
struct ShowAlert: ViewModifier {
#Binding var showingAlert: Bool
let title: String
let message: String
func body(content: Content) -> some View {
content
.alert(isPresented: $showingAlert) { () -> Alert in
Alert(title: Text(title), message: Text(message),
primaryButton: .default (Text("OK")) {
print("OK button tapped")
//How to trigger ok button clicked event to my content view
},secondaryButton: .cancel())
}
}
}
View Implementation
ScrollView {
....
}.navigationBarTitle("Click")
.navigationBarItems(trailing: Button(action: {
self.showAlert = true
}) {
Image(systemName: "lock")
}.modifier(ShowAlert(showingAlert: $showAlert, title: "", message: "Are you sure you want to Logout"))
Here is a demo of solution with passed callback into modifier. Tested with Xcode 11.4 / iOS 13.4.
struct ShowAlert: ViewModifier {
#Binding var showingAlert: Bool
let title: String
let message: String
var callback: () -> () = {} // << here !!
func body(content: Content) -> some View {
content
.alert(isPresented: $showingAlert) { () -> Alert in
Alert(title: Text(title), message: Text(message),
primaryButton: .default (Text("OK")) {
print("OK button tapped")
self.callback() // << here !!
},secondaryButton: .cancel())
}
}
}
// Demo view
struct TestAlertModifier: View {
#State private var showAlert = false
#State private var demoLog = "Wait for alert..."
var body: some View {
NavigationView {
ScrollView {
Text(demoLog)
}.navigationBarTitle("Click")
.navigationBarItems(trailing: Button(action: {
self.showAlert = true
}) {
Image(systemName: "lock")
}.modifier(ShowAlert(showingAlert: $showAlert, title: "",
message: "Are you sure you want to Logout", callback: confirmAlert)))
}
}
private func confirmAlert() {
self.demoLog = "Tapped - OK"
}
}

Resources