Reputation: 2113
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()
}
}
Upvotes: 15
Views: 4903
Reputation: 49580
@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.
Upvotes: 10
Reputation: 2113
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()
}
}
Upvotes: 1
Reputation: 4082
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()
}
}
Upvotes: 6
Reputation: 36181
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")
})
}
Upvotes: -1