Satoshi Nakajima
Satoshi Nakajima

Reputation: 2113

Changes in nested ObservedObject do not updated the UI

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

Answers (4)

New Dev
New Dev

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

Satoshi Nakajima
Satoshi Nakajima

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

Ralf Ebert
Ralf Ebert

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

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

Related Questions