SwiftUI Bindings - does reading them in body mean view will re-render?

If I have a View like this, that both gets and sets a value through a Binding:

struct MyView: View {
    @Binding var number: Int
    var body: some View {
        ...
        Text("Using the binding: \(number)")
        Button(action: self.changeIt) { Text("Change it") }
        ...
    }

    func changeIt() { number += 1 }

Can I expect that tapping the button – setting the value behind the Binding – will cause MyView to re-render?

That's what I thought I could expect. But it turns out to work reliably only if the Binding is connected to a State<Int>. If it's connected to an ObservableObject's Int property, it doesn't always work. I can post more detailed example code, but first I'm wondering if this is supposed to work.

Rob

1 Like

I’m not sure what you mean by connect, but SwiftUI only tracks @State and @ObservedObject variables inside a View. More accurately,

  • Any views that reads from @State var foo are invalidated whenever foo changes, wherever foo is declared, and
  • Any views that has @ObservedObject var foo are invalidated whenever foo.objectWillChange is triggered.

Binding is just a way to read from/write to variables across views. If you mutate to ObservableObject, it will trigger objectWillChange, but if the view that mutates it does not observe it with @ObservedObject the view will not be invalidated.

PS

SwiftUI is Apple’s private framework. I’d suggest that you post on Apple’s Developer forum instead.

So, if you have observable Foo, you may want to instead do:

struct MyView: View {
    @ObservedObject var foo: Foo
    var body: some View {
        ...
        Text("Using the binding: \(foo.number)")
        Button(action: self.changeIt) { Text("Change it") }
        ...
    }

    func changeIt() { foo.number += 1 }
}

The reason I haven't done that is that MyView is a view of a small piece of the ObservableObject. That model object has an array of items, and MyView is trying to show one of those items. It seemed more appropriate to pass Bindings to only the thing that MyView deals with, not the entire object. But as I mentioned in the first post, that's not working as well as when you pass Bindings to State.

I'll try the dev forums, but that forum software, especially the text editor, is such garbage that it's gives the impression Apple doesn't want people using it.

You need to observe changes in some way. You can probably observe the entire model, or have array of observables and observe them individually. As I said, Binding does nothing regarding view invalidation.

I'm not sure about that. If I have a View that has only a Binding<Int>, that view will get invalidated and re-rendered when the underlying state changes. That's what I'm seeing.

Maybe I’m not clear. What I’m trying to say is, there is no logic within Binding regarding state invalidation.

If you have

struct Container {
  @State var foo
  var body: some View {
    Nested(binding: $foo)
  }
}

struct Nested {
  @Binding var binding
  var body: some View {
    Text(String(binding))
  }

  func mutate() {
    // mutate binding
  }
}

Now you have Container.foo as the only state in this part of the hierarchy.
Nested.binding only refers to Container.foo in this case.

So if you call Nested.mutate, you’ll then try to mutate foo via binding. Since foo is mutated, any views that reads from it gets invalidated. So Nested is invalidated because it is reading foo via binding.

Note that there is no Nested.binding state in the picture, it is just an alias to Container.foo.

I got it. You're crystal clear there, and I was trying to talk about the same thing. Let me share some more detailed code that maybe will show why I'm confused.

If you run this as is, with the ObservableObject model, and then comment out the object and use the State instead, you'll see that the two behave differently. In the first PushedView, if you tap the button to incrementIt (like Nested.mutate in your example code), then the object changes and the view updates. But if you push a second PushedView, now it doesn't work. I don't understand why. Seems broken to me.

If I use the State instead of the object (the lines commented out), then I can have any number of pushed views and everything updates as expected through the Binding.

class MyObject: ObservableObject {
    @Published var number: Int = 1
}
struct ContentView: View {
    // This one works as expected.
    // @State private var number: Int = 1
    // This does not:
    @ObservedObject var myObject: MyObject
    
    var body: some View {
        NavigationView {
            VStack {
                // Text("number: \(number)")
                Text("number: \(myObject.number)")
                NavigationLink(destination: PushedView(number: $myObject.number, pushLevel: 1)) {
                    Text("Push a View")
                }
            }
        }
    }
}

struct PushedView: View {
    @Binding var number: Int
    let pushLevel: Int
    func incrementIt() {
        number += 1
    }
    var body: some View {
        VStack {
            Text("Pushed View (level \(pushLevel))")
            Text("number (via binding): \(number)")
            Button(action: self.incrementIt) {
                Text("Increment number")
            }
            NavigationLink(destination: PushedView(number: $number, pushLevel: pushLevel+1)) {
                Text("Push a View")
            }
        }
    }
}

I can see where the problem is. Views inside NavigationLink are not part of the current hierarchy. You successfully invalidate ContentView via binding (resulting in number change in ContentView text), but the invalidation doesn't apply to PushedView because it's in a different hierarchy. You'll need to re-observe to myObject in the new hierarchy.

The State version works because you also read from ContentView.number again in PushView. So when number changes, it invalidates both ContentView and PushView.

There is a problem that EnvironmentObject doesn't travel across NavigationLink for the same reason. Whether it's intentional, :woman_shrugging:, but the State and ObservedObject behavior is most likely intended.

Remember, in my example, Nested itself is never invalidated directly, it is only part of an invalidation cascade originating from Container. The same happens in your example, only that the cascade from ContentView never reaches PushedView.

All in all, be extra careful when you cross NavigationLink boundaries.

This is not correct. Bindings ought to trigger updates the same as the thing they're bound to. We've filed a bug about the bad interaction with NavigationLink here.

3 Likes

Seems to be missing a link.

For reference, we filed rdar://problem/60594597.

1 Like

Is it the same as what I'm saying here? If not, could you elaborate some more?

A Binding should not trigger an update on the same view. It should update the storage to which it is bound and that mutation may or may not eventually cascade a view tree to recompute which can cause the view we‘re talking about to update. It doesn‘t have to though.

I‘ve seen a loooot of people having issues with ObservableObject and Published. In nearly all cases it was due to the hacky default implementation for objectWillChange. If they added a single line to their models, that would explicitly initialize that instance with the default type proposed by ObservableObject protocol, it would solve the issues.

1 Like

I guess I should let him answer :), but I'm pretty sure he means that if a binding is read/used within a view body, then that view becomes dependent on the state underlying that binding, and should re-render when that state changes. I'm not sure how the implementation works but my guess is that SwiftUI records all access to State/ObservedObjects during the call to var body. So when you were saying before that I needed to "observe" something, I don't think that was right. I was observing it, because I had used the binding's value within var body.

Nested.binding is indeed a reference to Container.foo, which means that components which reference Container.foo through a binding get updated when the state is updated. In yor example, that would happen regardless, because Nested is nested inside the Container that owns the state, but if you had passed the Binding along into a different part of the hierarchy, then that other part of the hierarchy should receive updates.

1 Like

I'm still pretty sure ObservedObject is the only way for SwiftUI to know that the view is observing some ObservableObject. The read logic is part of the ObservableObject itself and SwiftUI has no hands in that, unlike State.

Perhaps ObservedObject.projectedValue can add extra logic to let SwiftUI know before wrapping it in Binding, but I don't think it has anything like that right now. If there should be, then, well, the current behavior would be a bug.

That is not fully correct, the subview which has the Binding produced by the parent view that has ObservedObject 'may' also update if the mutation causes the parent to update, this is basically a cascading effect, which will then also supply the subview with a brand new Binding.

1 Like

Yea, I should've said that it won't be invalidated directly, but could be from an invalidation cascade (mostly parent -> child). Hopefully my later post was a bit clearer.