swiftui

Propagation of published changes in Views


I use the new @Observable macro for my ViewModel. The class looks like this

@Observable
class TransitiveModel {

    private var strings: [String] = []

    func getStrings() -> [String] {
        return strings
    }

    func addString(_ item: String) {
        strings.append(item)
    }
}

This is a simplification of my actual class, but it serves as a minimum example. You could argue that in this example a getStrings() method might not be needed, however in my actual class I pass parameters and do some calculations before, so please pretend it is important.

I have a simple view

private var model = TransitiveModel()

var body: some View {
    VStack {
        ForEach(model.getStrings(), id: \.self) { item in
            Text(item)
        }

        Button("Add Item") {
            model.addString("Hello")
        }
    }
}

When I Press the Button, I expected nothing to change visually, because I am not directly subscribed to the strings-array in the View. However I observed that the View will update anyway, most probably because getStrings() relies on the array, which has been changed. I don't want to rely on this behaviour unless I am sure it is correct and not a glitch or something like that. Am I right to think that the change in strings will trigger the function to be called again? And is there some kind of documentation that will help me in understanding it?


Solution

  • SwiftUI can still find dependencies of views even if you do not "directly" access an @Observable property. As long as the getter/setter of an @Observable property is called during the execution of View.body, that property will be found as a dependency of that view.

    This is because the @Observable macro generates extra code in the getter/setter of those properties to register accesses/mutations of those properties into an observation registrar, which SwiftUI then uses to track dependencies.

    This generated code doesn't look at the call stack and goes "this is not directly called from View.body so I will not register this access". After all, Observation is not supposed to be strongly coupled with SwiftUI. And why would it do that extra work when the opposite is typically more desirable?

    For documentation, off the top of my head I can think of the 2 WWDC videos Discover Observation in SwiftUI where they explain how Observation works, and Demystifying SwiftUI, where they talk about what a "dependency" of a view is.

    As for the question in the comments:

    Will this still be the case if there was no button but the change of strings was caused somewhere else?

    Yes. Since the getter of strings is called in body, the SwiftUI view will update whenever the setter of strings is called, from anywhere.


    If you don't want the view to change visually when models.strings change for some reason, you can add an extra @State to separately store the strings. This @State will be independent of model.strings.

    // model should be a @State regardless
    @State private var model = TransitiveModel()
    
    // add an extra @State here so that the view does not depend on model.strings
    @State private var strings = [String]()
    
    var body: some View {
        VStack {
            ForEach(strings, id: \.self) { item in
                Text(item)
            }
    
            Button("Add Item") {
                model.addString("Hello")
            }
        }
        .onAppear {
            // set strings to model.strings only initially
            strings = model.getStrings()
        }
    }