How to force a refresh of a SwiftUI View? - ios

I have a button with a .disabled() condition that's a computed property of my ObservableObject model class. This means I can't make it #Published.
Something like this:
class MyModel : ObservableObject {
var isDisabled: Bool {
if ... {
return true
}
else {
return false
}
}
}
struct SettingsNewsSubscriptionsView : View {
#ObservedObject var model: MyModel
var body: some View {
...
Button("Save") {
Task {
// Saves to backend asynchronously, then updates MyModel which changes isDisabled.
}
}
.disabled(model.isDisabled)
}
}
At some point isDisabled is false. When the button is tapped something is saved asynchronously after which MyModel is updated which will result in isDisabled to become true.
Because isDisabled is not #Published, the button does not update once isDisabled becomes true.
How can I trigger the refresh of a SwiftUI View, in this case Button explicitly from within a Task?

Call
model.objectWillChange.send()

You can try making the property stored instead of computed, that way you can use #Published

Related

SwiftUI private route based on Authentication Status

I need to implement a mechanism just like react private and public route on swiftUI. Basically I have tens of views and some of these views requires authentication based on user logged in status. So far I have tried to hold current screen in an Environment Object as show in following class
enum Routes {
case screenA,
screenB,
screenC,
screenD,
screenE,
screenF,
screenG,
loginScreen
var isAuthRequired: Bool {
if case . screenA = self {
return true
} else if case . screenD = self {
return true
} else {
return false
}
}
}
class AuthenticatedRoute: ObservableObject {
#Published var currentRoute: Routes
init(){
self.currentRoute = . screenA
}
}
And on my main screen I check every time the current screen change whether user loggedin and current page require authentication.
struct MainView: View {
#StateObject var authenticatedRoute = AuthenticatedRoute()
#EnvironmentObject var userAuth: UserAuth
var body: some View {
mainView()
.environmentObject(authenticatedRoute)
}
#ViewBuilder
func mainView() -> some View {
if (self.authenticatedRoute.currentRoute.isAuthRequired && !userAuth.isLoggedIn) {
LoginView()
}
else {
DefaultTabView()
}
}
}
And this is an example of how I keep changing this environment variable. I change the environment object onAppear event method of view.
struct ScreenA: View {
#EnvironmentObject var authenticatedRoute: AuthenticatedRoute
var body: some View {
NavigationView {
someContent()
}.onAppear {
authenticatedRoute.currentRoute = .screenA
}
}
}
While this approach works for most cases for some reason it behave strange when a screen is in tab navigation. Also I do not feel comfortable with this solution, that I need to change screen name manually on every single page, and checking authentication status in main view. I think it would be better somehow if I can write a kind of interceptor before every page change and check if desired destination requires authentication and if user is authenticated but I could not find a way manage this. I'm relatively new to iOS development and had experience with react native but this should not be so hard to implement in my opinion since this is a requirement for most applications.
So basically I need to implement a private and public router in swiftUI or intercept every page change so I should not modify environment variable on each pages manually and should not check conditions in MainView inside a function.
I can propose another approach, that does not require the class AuthenticatedRoute, I hope it's what you are looking for. The process is:
In the class UserAuth, create a static shared instance, that can be called anywhere in your code and ensures you are always using the same instance.
Create a modifier extending View, that reads the status in UserAuth.shared and shows the necessary view according to whether the authentication is required (LoginView() if the user is not authenticated).
Use the modifier at the outermost container (VStack, NavigationView, whatever) of any view that requires the user to be authenticated.
The example below shows how this can work, if you want to run it:
1. Static UserAuth instance
class UserAuth: ObservableObject {
static let shared = UserAuth() // This is to assure that you refer to the same instance all over the code
#Published private(set) var isLoggedIn = false // Use the variable you already have
func logInOrOff() { // Implement each func as needed
isLoggedIn.toggle()
}
}
2. Create the View modifier
extension View {
#ViewBuilder
func requiresAuthentication() -> some View {
if UserAuth.shared.isLoggedIn { // "shared" is the same instance used by the views
self
} else {
LoginView()
}
}
}
3. Apply the modifier at the bottom of the view that requires authentication
struct Example: View {
#StateObject private var userAuth = UserAuth.shared // Or #EnvironmentObject, as you wish
var body: some View {
VStack {
Text(userAuth.isLoggedIn ? "Now we're good" : "You must log in")
.padding()
Button {
userAuth.logInOrOff()
} label: {
Text("Logoff")
}
}
.requiresAuthentication() // This is what makes your view safe
}
}
struct LoginView: View {
var body: some View {
VStack {
Text("Authentication is required")
.padding()
Button {
UserAuth.shared.logInOrOff()
} label: {
Text("Log in")
}
}
}
}

How to bind a picker to a computed value SwiftUI

I have a view SettingsView written using swiftui, and a data store SettingsStore that uses UserDefaults as the underlying data storage.
final class SettingsStore: ObservableObject {
var velocityLocation: RenderLocation {
set { defaults.set(newValue.rawValue, forKey: Keys.velocityLoaction) }
get { RenderLocation(rawValue: defaults.integer(forKey: Keys.velocityLoaction)) ?? .top }
}
}
struct SettingsView: View {
#State var settings: SettingsStore = SettingsStore();
var body: some View {
Form {
Section(header: Text("Render settings")) {
Toggle("Render velocity to video", isOn: $settings.renderVelocityToVideo)
Picker(selection: $settings.velocityLocation, label: Text("Render location")) {
ForEach(RenderLocation.allCases) { b in
Text(b.name).tag(b)
}
}
}
}
.navigationTitle("Settings")
}
}
When I pick a new value in the picker it is persisted to user defaults correctly, but it is not rendered to the view. This is because the get for the computed property is never called. If I leave that view and come back the picker now has the correct value.
I know my use of an Enum in the picker works as when I change the computed property in SettingsStore, to a simple #State property in the view everything works as expected (except of course saving to UserDefaults)
Is there a way to get computed property to render after being set or is there a better way to structure this code so that i can persist picker values easily.
(Yes I have tried this with other data types eg ints, string same issue)

How to handle onChange of Text event in SwiftUI?

I have a ContentView where I have a variable:
#State private var textToShow = "Some Text"
That I show in:
Text(textToShow)
I have a button where when I click it, it changes textToShow to equal "Changed Text". What is the right way to attach some kind of event that triggers when the Text changes? I am looking for something like a Text(textToShow).onChange(print("Text Changed")).
Note that I do not have any IBAction and I am not using any storyboards.
I would trigger any side-effects as early as possible. That would be the button-action. If you trigger side-effects from side-effects from side-effects it will become hard to track all changes that may occur when you tap the button. I used to implement such a chain of multiple bindings. It was horrible to maintain.
If you still want to observe the Text-View, then you may just observe the state itself and not the Text-View. Side-effects can be trigged by the View‘s onChange-modifier. https://developer.apple.com/documentation/swiftui/view/onchange(of:perform:)
Text(textToShow)
.onChange(of: textToShow) { newValue in
print(...)
}
You could also achieve this using an ObservableObject.
First, declare a model class that conforms to the ObservableObject protocol. This will store the text and apply a didSet property observer.
class Model: ObservableObject {
#Published var textValue: String = "" {
didSet {
print("Text changed!")
}
}
}
Then use it in the relevant view.
struct ContentView: View {
// Create observed object
#ObservedObject var model = Model()
var body: some View {
Text($model.textValue)
}
}

SwiftUI observed object do action when its property change

I have observed object which is created in view and I want to have function that will occur when the object bool property is changed. I want something similar to .onTapGesture. Is there a way to do it? have function where the body of the function is created outside of that object?
Let's replicate...
class Demo: ObservableObject {
#Published var value = false
}
struct DemoView: View {
#ObservedObject var demo = Demo()
var body: some View {
Text("Demo")
.onReceive(demo.$value) { flag in
// call here your function
}
}
}
use #ObservedObject to make change in value
an use #StateObject to read the published value

SwiftUI, how to bind EnvironmnetObject Int property to TextField?

I have an ObservableObject which is supposed to hold my application state:
final class Store: ObservableObject {
#Published var fetchInterval = 30
}
now, that object is being in injected at the root of my hierarchy and then at some component down the tree I'm trying to access it and bind it to a TextField, namely:
struct ConfigurationView: View {
#EnvironmnetObject var store: Store
var body: some View {
TextField("Fetch interval", $store.fetchInterval, formatter: NumberFormatter())
Text("\(store.fetchInterval)"
}
}
Even though the variable is binded (with $), the property is not being updated, the initial value is displayed correctly but when I change it, the textfield changes but the binding is not propagated
Related to the first question, is, how would I receive an event once the value is changed, I tried the following snippet, but nothing is getting fired (I assume because the textfield is not correctly binded...
$fetchInterval
.debounce(for: 0.8, scheduler: RunLoop.main)
.removeDuplicates()
.sink { interval in
print("sink from my code \(interval)")
}
Any help is much appreciated.
Edit: I just discovered that for text variables, the binding works fine out of the box, ex:
// on store
#Published var testString = "ropo"
// on component
TextField("Ropo", text: $store.testString)
Text("\(store.testString)")
it is only on the int field that it does not update the variable correctly
Edit 2:
Ok I have just discovered that only changing the field is not enough, one has to press Enter for the change to propagate, which is not what I want, I want the changes to propagate every time the field is changed...
For anyone that is interested, this is te solution I ended up with:
TextField("Seconds", text: Binding(
get: { String(self.store.fetchInterval) },
set: { self.store.fetchInterval = Int($0.filter { "0123456789".contains($0) }) ?? self.store.fetchInterval }
))
There is a small delay when a non-valid character is added, but it is the cleanest solution that does not allow for invalid characters without having to reset the state of the TextField.
It also immediately commits changes without having to wait for user to press enter or without having to wait for the field to blur.
Do it like this and you don't even have to press enter. This would work with EnvironmentObject too, if you put Store() in SceneDelegate:
struct ContentView: View {
#ObservedObject var store = Store()
var body: some View {
VStack {
TextField("Fetch interval", text: $store.fetchInterval)
Text("\(store.fetchInterval)")
}
} }
Concerning your 2nd question: In SwiftUI a view gets always updated automatically if a variable in it changes.
how about a simple solution that works well on macos as well, like this:
import SwiftUI
final class Store: ObservableObject {
#Published var fetchInterval: Int = 30
}
struct ContentView: View {
#ObservedObject var store = Store()
var body: some View {
VStack{
TextField("Fetch interval", text: Binding<String>(
get: { String(format: "%d", self.store.fetchInterval) },
set: {
if let value = NumberFormatter().number(from: $0) {
self.store.fetchInterval = value.intValue
}}))
Text("\(store.fetchInterval)").padding()
}
}
}

Resources