Is this a bug or am I using them wrong? Using List, ForEach, and NavigationLink together

I've been running into a hairy problem with SwiftUI that I've managed to condensed down into this simple example. When you run this code and click on the NavigationLink, ChildView doesn't update every second, even though it seems that it should.

What's most puzzling is that if you comment out the ForEach, it works as expected.

This has been a problem in one way or another on every version of iOS and Xcode I've tried it on while building an app over the past few months, but this example was specifically tested on iOS 14.4 (simulator and phone) and Xcode 12.4.

A version of (what seems to be) the same problem occurs when using @Binding to pass down the value (and potentially modify it in ChildView using a Toggle, for example, instead of a timer) and storing the value inside an @ObservableObject as a @Published property. The problem doesn't seem to be present when using @Binding with @State, at least in this simple example.

Finally, this only occurs when using NavigationLink inside a List that has a ForEach inside it (even if the NavigationLink isn't actually inside the ForEach). If we remove ForEach or replace List with VStack, this problem goes away (for the purposes of this issue, List(...) { acts the same as List { ForEach(...) {). If the NavigationLink (even inside a ForEach) is placed outside of List and controlled using isActive, the problem goes away.

Am I understanding how SwiftUI should be used correctly? Is this a bug, or am I using it wrong?

struct ParentView: View {
  @State var checked = false
  private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
  var body: some View {
    return NavigationView {
      List {
        RowView(checked: checked)
        ForEach([0], id: \.self) { _ in // comment this out to make it work
          RowView(checked: checked)
          NavigationLink(destination: ChildView(checked: checked)) {
            RowView(checked: checked)
          }
        } // and this, too
      }
    }.onReceive(timer) { _ in
      checked.toggle()
    }
  }
}

struct ChildView: View {
  let checked: Bool
  var body: some View {
    return List {
      RowView(checked: checked)
    }
  }
}

struct RowView: View {
  let checked: Bool
  var body: some View {
    HStack {
      Text("Boolean")
      Spacer()
      Image(systemName: checked ? "checkmark" : "xmark")
        .foregroundColor(.secondary)
    }
  }
}

I don't see how ChildView() can change in response to timer? With or without the ForEach: the ChildView is constructed with whatever the checked value is at the time.

You can make ChildView update by using a binding:

struct ChildView: View {
//    let checked: Bool
    @Binding var checked: Bool
    var body: some View {
        return List {
            RowView(checked: checked)
        }
    }
}

then change the NavLink:

NavigationLink(destination: ChildView(checked: $checked)) {
    RowView(checked: checked)
}

I'm not sure why you think ChildView is any different than RowView. RowView is updated every second as expected even though it's not using @Binding, just a regular property.

If you think about it, even the simplest example requires regular properties to update a child view. In the following example, the label for Text is passed down as a regular property (not a binding), but everyone agrees that this should work.

struct ExampleView: View {
  @State var checked: Bool
  var body: some View {
    List {
      Toggle("Toggle", isOn: $checked)
      Text("\(checked ? "true" : "false")")
    }
  }
}

That's because ParentView has a timer making change to a @​State that cause it to update. Once you navigate away from ParentView, ChildView is on its own: there is nothing driving it to change.

Huh, when I add print statements to the body methods, I see that ParentView.body is called every second even when it's not visible because I've navigated away from it to ChildView. Shouldn't that cause it to update? ChildView.body is not called every second. Shouldn't it be?

Furthermore, why does ChildView update if there's no ForEach?

The ParentView is still in the NaV stack, timer still fire and ParentView still receive the event. But ChildView is created once when you click on the NavLink and it's shown on screen with whatever checked value you pass into its init

That's how timer works in SwiftUI NavigationView stack.

You also need to shut off timer when your app enter .background according to Apple Guidliine.

But that's clearly not true (at least not always), because when you remove ForEach, the ChildView does show on screen with subsequently updated checked value every second, and is not just stuck with the value you pass into its init the first time...

Comment out ForEach, I don't see ChildView update every second. It's whatever the checked value at time of NavLink click.

Wow this is very unpredictable then. I just did it again and on my simulator ChildView updates every second. Are you running a different Xcode (12.4) or iOS (14.4) version than me?

Very strange.


I tried adding a NavigationLink(destination: ChildView(checked: checked)) inside ChildView so that there are multiple levels of navigation. On my simulator, the 3rd click (fourth level) leads to an instance of ChildView that doesn't update every second, even though the previous instances "above" it do.

I've also tried replacing List with VStack and the same occurs. I can see that you're right that passing data to destination views in NavigationLink using simple properties doesn't reliably update the destination view when the parent view is updated. It's particularly alarming that you're seeing different behavior than I am for the second level of navigation.

I'm confused about your proposed solution, though. How could Binding cause another view to update? I don't see any mechanism by which it can let child views know that the value has been updated. Unlike ObservableObject, which has objectWillChange, Binding only has a wrappedValue getter and setter, AFAIK.

It works, though. I just have no idea why, or how someone could come to this conclusion from reading the documentation.


In particular, I'm also not sure why putting the state in an ObservableObject like so breaks it again:

class Model: ObservableObject {
  @Published var checked = false
}

struct ParentView: View {
  @StateObject var model = Model()
  // ...
  NavigationLink(destination: ChildView(checked: $model.checked))
}

If Binding somehow propagates updates down through NavigationLink destinations, why does it then break when the Binding comes from an ObservableObject instead of State? How does Binding work in the State case, so I can understand why it doesn't work with ObservableObject?

Any clarity you can bring on this would be appreciated. Thank you so much for taking the time you've already taken to help.

Sorry, you're correct: removing ForEach does indeed makes ChildView update when run in Simulator or device. I got mislead by Xcode SwiftUI preview: it's unreliable should not be used.

Seems like this Navigation stack is like a view hierarchy so the off screen ParentView and the ChildView is one view tree: on timer fire, the new ChildView get swap in. And the ParentView is changed also even though it's not on screen. I've seen this interferes with keyboard in child view before (but not a problem anymore I just now checked).

I am guessing, but pretty sure it doesn't work when in List of ForEach is because it's "lazily": child views are only created on demand and cached so no re-created when timer fire. The ForEach content closure is @​escaping, unlike VStack/HStack/Group.

If my guess is correct, then you cannot rely on view getting swap out and new in in this situation. Use a binding.

That's odd. Would love to know why ...

I don't know how it works, but it does behave like an @​State var as far as view update goes. This is how you can reference "single source of true".

I'm sure some SwiftUI person can explain all these. @luca_bernardi ?

@luca_bernardi or anyone on the SwiftUI team, do you have any insights to explain the strange behavior observed here? I haven't been able to find anything in the SwiftUI documentation that describes a mental model that could explain this. It seems like everyone's just guessing at what could possibly be happening. In my own app, I ended up working around this by avoiding passing down dynamic data through NavigationLinks as much as possible, but it's hard to have any certainty about my (or any) app's reliability without knowing what SwiftUI behavior is supposed to be, so I can count on it.

Am I misusing SwiftUI here, or is there a bug in SwiftUI that I'm running into?

For reference, the problem is that using a State variable in a root view that gets passed down through multiple levels of NavigationLink doesn't update some descendant destinations when the State variable is updated in the root view. The problem happens faster (higher in the navigation stack) when using List and ForEach, but also occurs even when using VStack and no ForEach.