When I have a nested ObservedObject, changes in a published property of a nested object do not updated the UI until something happens to the parent object. Is this a feature, a bug (in SwiftUI) or a bug in my code?
Here is a simplified example. Clicking the On/Off button for the parent immediately updates the UI, but clicking the On/Off button for the child does not update until the parent is updated.
I am running Xcode 12.5.1.
import SwiftUI
class NestedObject: NSObject, ObservableObject {
#Published var flag = false
}
class StateObject: NSObject, ObservableObject {
#Published var flag = false
#Published var nestedState = NestedObject()
}
struct ContentView: View {
#ObservedObject var state = StateObject()
var body: some View {
VStack {
HStack {
Text("Parent:")
Button(action: {
state.flag.toggle()
}, label: {
Text(state.flag ? "On" : "Off")
})
}
HStack {
Text("Child:")
Button(action: {
state.nestedState.flag.toggle()
}, label: {
Text(state.nestedState.flag ? "On" : "Off")
})
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
#ObservedObject or #StateObject updates the view when the ObservableObject updates. This happens when a #Published property is changed or when you directly call objectWillChange.send().
So, the "normal" (and the simplest) approach is to use a value type, e.g. a struct, for a #Published property.
struct NestedObject {
var flag = false
}
The reason this works, is that the entire NestedObject changes when its properties are modified, because struct is a value-type. In contrast, a reference-type class doesn't change (i.e. reference remains the same) when its property is modified.
But, sometimes you might need it to be a reference-type object, because it might have its own life cycle, etc...
In that case, you could definitely just call state.objectWillChange.send(), but that would only work if the view initiates the change, not when the nested object initiates the change. The best general approach here, in my opinion, is to use a nested inner view that has its own #ObservedObject to observe changes of the inner object:
struct ContentView: View {
private struct InnerView: View {
#ObservedObject var model: NestedObject
var body: some View {
Text("Child:")
Button(action: {
model.flag.toggle()
}, label: {
Text(model.flag ? "On" : "Off")
})
}
}
#StateObject var state = OuterObject() // see comments 1, 2 below
var body: some View {
VStack {
HStack {
Text("Parent:")
Button(action: {
state.flag.toggle()
}, label: {
Text(state.flag ? "On" : "Off")
})
}
HStack {
InnerView(model: state.nestedObject)
}
}
}
}
1. You shouldn't call your class StateObject, since it clashes with the StateObject property wrapper of SwiftUI. I renamed it to OuterObject.
2. Also, you should use #StateObject instead of #ObservedObject if you instantiate the object inside the view.
It works as supposed: ObservableObject only sends a notification about the changes of #Published properties but doesn't propagate change notification of nested ObservableObjects.
I'd follow the advice from New Dev: use a struct when you can or use a separate View to subscribe to the nested object.
If you truly need nested ObservableObjects, you can propagate the objectWillChange event from the nested object to the outer object using Combine:
import Combine
import SwiftUI
class InnerObject: ObservableObject {
#Published var flag = false
}
class OuterObject: ObservableObject {
#Published var flag = false
var innerObject = InnerObject() {
didSet {
subscribeToInnerObject()
}
}
init() {
subscribeToInnerObject()
}
private var innerObjectSubscription: AnyCancellable?
private func subscribeToInnerObject() {
// subscribe to the inner object and propagate the objectWillChange notification if it changes
innerObjectSubscription = innerObject.objectWillChange.sink(receiveValue: objectWillChange.send)
}
}
struct ContentView: View {
#ObservedObject var state = OuterObject()
var body: some View {
VStack {
Toggle("Parent \(state.flag ? "On" : "Off")", isOn: $state.flag)
Toggle("Child \(state.innerObject.flag ? "On" : "Off")", isOn: $state.innerObject.flag)
}
.padding()
}
}
Thank you for clarifications. The most valuable take-away for me is the fact that this behavior is not a bug (of SwiftUI), but is a by-design behavior.
SwiftUI (more precisely, Combine) see changes only in values, therefore, it can see changes in the property value changes of #Published struct instances, but not #Published class instances.
Therefore, the answer is "use struct instances for the nested objects if you want to update the UI based on the changes in the property values of those nested objects. If you have to use class instances, use another mechanism to explicitly notify changes".
Here is the modified code using struct for NestedObject instead of class.
import SwiftUI
struct NestedObject {
var flag = false
}
class OuterObject: NSObject, ObservableObject {
#Published var flag = false
#Published var nestedState = NestedObject()
}
struct ContentView: View {
#ObservedObject var state = OuterObject()
var body: some View {
VStack {
HStack {
Text("Parent:")
Button(action: {
state.flag.toggle()
}, label: {
Text(state.flag ? "On" : "Off")
})
}
HStack {
Text("Child:")
Button(action: {
state.nestedState.flag.toggle()
}, label: {
Text(state.nestedState.flag ? "On" : "Off")
})
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
try this, works well for me on ios-15, catalyst 15, macos 12, using xcode 13:
HStack {
Text("Child:")
Button(action: {
state.objectWillChange.send() // <--- here
state.nestedState.flag.toggle()
}, label: {
Text(state.nestedState.flag ? "On" : "Off")
})
}
I've been using #AppStorage and UserDefaults for updates in SwiftUI. If I make a change to the vie that has the #AppStorage wrapper all works well. I'm confused with how to make this work globally.
I'm using a struct that has computed properties and formatters associated. The idea is to check user defaults and convert items to lbs or kg. The issue is that the views using the computed properties do not update when UserDefaults is updated. Is there a way to create a global change that would update weightFormatted in SecondaryView below?
// Weight Struct
struct Weight {
var weight: Double
var weightFormatted: String {
return weightDecimalLbsOrKgFormatted2(weight)
}
// Formatting Method
func weightDecimalLbsOrKgFormatted2(_ lbs: Double) -> String {
if (!UserDefaults.standard.bool(forKey: "weightInKilograms")) {
let weightString = decimalFormatterDecimal2(lbs)
return weightString + "lbs"
} else {
let kg = toKg(lbs)
let weightString = decimalFormatterDecimal2(kg)
return weightString + "kg"
}
}
// Where weightInKilograms Is Set
struct AccountView: View {
#AppStorage("weightInKilograms") var weightInKilograms = false
let weight = Weight(weight: 9.0))
var body: some View {
VStack {
Text(weight.weightFormatted)
Toggle(isOn: $weightInKilograms) {
Text("Kilograms")
}
}
}
}
// Secondary View Not Updating
struct SecondaryView: View {
let weight = Weight(weight: 9.0))
var body: some View {
Text(weight.weightFormatted)
}
}
Your problem is that weight isn't wrapped by any state.
In your AccountView, give weight a #State wrapper:
struct AccountView: View {
#AppStorage("weightInKilograms") var weightInKilograms = false
#State var weight = Weight(weight: 9.0))
var body: some View {
//...
}
}
In SecondaryView, ensure that weight is wrapped with #Binding:
struct SecondaryView: View {
#Binding var weight: Weight
var body: some View {
// ...
}
}
Then, pass weight as a Binding<Weight> variable to SecondaryView within your first View:
SecondaryView(weight: $weight)
Is there a way to create a global change that would update weightFormatted in SecondaryView below?
If you're looking to make a global change, you should consider setting up a global EnvironmentObject:
class MyGlobalClass: ObservableObject {
// Published variables will update view states when changed.
#Published var weightInKilograms: Bool
{ get {
// Get UserDefaults here
} set {
// Set UserDefaults here
}}
#Published var weight: Weight
}
If you pass an instance of MyGlobalClass as an EnvironmentObject to your main view, then to your secondary view, any changes made to properties in the global instance will update the views' state via the #Published wrapper:
let global = MyGlobalClass()
/* ... */
// In your app's lifecycle, or where AccountView is instantiated
AccountView().environmentObject(global)
struct AccountView: View {
#EnvironmentObject var global: MyGlobalClass
var body: some View {
// ...
Text(global.weight.weightFormatted)
// ...
SecondaryView().environmentObject(global)
}
}
Sorry for terrible wording, but in onAppear I am changing a State variable, however this does not re-render the view unless I use that State variable somewhere inside the view.
I am trying to pop a modal if the user is not logged in, but it won't pop unless I put the variable in the view like shown below
struct SearchView: View {
// Environment Variables
#EnvironmentObject var session: SessionStore
#State var sheetIsPresented: Bool = false
var body: some View {
NavigationView {
// Just having a line like this will cause it to work
if self.sheetIsPresented || !self.sheetIsPresented {}
}
.onAppear {
if self.session.userSession != nil {
self.sheetIsPresented = true
}
}
.sheet(isPresented: $sheetIsPresented) {
WelcomeSignInModal(sheetIsPresented: self.$sheetIsPresented)
}
}
}
You can try the following demo:
class SessionStore: ObservableObject {
var userSession: String? = "session"
}
struct ContentView: View {
#EnvironmentObject var session: SessionStore
#State var sheetIsPresented: Bool = false
var body: some View {
NavigationView {
Text("SearchView")
}
.onAppear {
self.sheetIsPresented = self.session.userSession != nil
}
.sheet(isPresented: $sheetIsPresented) {
Text("WelcomeSignInModal")
}
}
}
Tested with Xcode 11.6, iOS 13.6
According to my understanding, if you define a view yourself (as a struct that implements View), then you can declare some var to be an Environment variable, like this:
#Environment(\.isEnabled) var isEnabled
This will give you access to the EnvironmentValues.isEnabled field.
However, it seems like this is only possible within the view definition itself.
Is it possible, given some view v, to get the environment object of that view? or get specific environment values?
I assume that taking into account that SwiftUI is reactive state managed framework then yes, directly you cannot ask for internal view environment value, actually because you can't be sure when that environment is set up for this specific view. However you can ask that view to tell you about its internal state, when view is definitely knowns about it... via binding.
An example code (a weird a bit, but just for demo) how this can be done see below.
struct SampleView: View {
#Environment(\.isEnabled) private var isEnabled
var myState: Binding<Bool>?
var body: some View {
VStack {
Button(action: {}) { Text("I'm \(isEnabled ? "Enabled" : "Disabled")") }
report()
}
}
func report() -> some View {
DispatchQueue.main.async {
self.myState?.wrappedValue = self.isEnabled
}
return EmptyView()
}
}
struct TestEnvironmentVar: View {
#State private var isDisabled = false
#State private var sampleState = true
var body: some View {
VStack {
Button(action: {
self.isDisabled.toggle()
}) {
Text("Toggle")
}
Divider()
sampleView()
.disabled(isDisabled)
}
}
private func sampleView() -> some View {
VStack {
SampleView(myState: $sampleState)
Text("Sample is \(sampleState ? "Enabled" : "Disabled")")
}
}
}
struct TestEnvironmentVar_Previews: PreviewProvider {
static var previews: some View {
TestEnvironmentVar()
}
}
I want to change the result of the bool Enabled, but don't know how to do it.
I need the bool to enable/disable a function in another UIViewRepresentable.
Code:
import SwiftUI
var Enable = true
struct ContentView: View {
#State private var Route = false
var body: some View {
VStack{
Button(action: {
self.Route.toggle()
}) {
Text("Route >")
}
if Route {
Enable = false
}
}
}
}
If I use this code it prints the error:
Argument type '()' does not conform to expected type 'View'
As mentioned in comments, by convention, variables should begin with lowercase and types (classes, enum, struct) with uppercase.
Also, your code should go inside the closure.
var enable = true
struct ContentView: View {
#State private var route = false
var body: some View {
VStack{
Button(action: {
self.route.toggle()
if self.route { enable = false }
}) {
Text("Route >")
}
}
}
}