Hi @grinder81! You're encountering a SwiftUI bug that's been brought up a few times before. Including here: How to manage ForEachStore with NavigationLink's binding? - #2 by stephencelis
And on our issue tracker. A potential fix and basic explanation here: Multiple levels of navigation · Issue #66 · pointfreeco/swift-composable-architecture · GitHub
It's a bit difficult to see in running the vanilla SwiftUI code you posted above, but it has the exact same problem. In fact, we can debug by introducing a binding helper:
extension Binding {
func debug(_ prefix: String) -> Binding {
Binding(
get: {
print("\(prefix): getting \(self.wrappedValue)")
return self.wrappedValue
},
set: {
print("\(prefix): setting \(self.wrappedValue) to \($0)")
self.wrappedValue = $0
})
}
}
And enhancing each binding:
// In `TopListView`:
.debug("TopListView")
...
// In `CategoryView`:
.debug("CategoryView")
Now when we run your playground the following will print when the top view first displays:
TopListView: getting nil
TopListView: getting nil
TopListView: getting nil
TopListView: getting nil
TopListView: getting nil
TopListView: getting nil
TopListView: getting nil
TopListView: getting nil
If you drill down by tapping the second row, the following will print:
TopListView: setting nil to Optional(399EC22A-F9A5-451D-8A4E-40F0B6F101BB)
TopListView: getting Optional(399EC22A-F9A5-451D-8A4E-40F0B6F101BB)
TopListView: getting Optional(399EC22A-F9A5-451D-8A4E-40F0B6F101BB)
TopListView: getting Optional(399EC22A-F9A5-451D-8A4E-40F0B6F101BB)
TopListView: getting Optional(399EC22A-F9A5-451D-8A4E-40F0B6F101BB)
TopListView: getting Optional(399EC22A-F9A5-451D-8A4E-40F0B6F101BB)
TopListView: getting Optional(399EC22A-F9A5-451D-8A4E-40F0B6F101BB)
TopListView: getting Optional(399EC22A-F9A5-451D-8A4E-40F0B6F101BB)
1
CategoryView: getting nil
CategoryView: getting nil
CategoryView: getting nil
CategoryView: getting nil
CategoryView: getting nil
CategoryView: getting nil
CategoryView: getting nil
And finally, if you drill down into a category:
CategoryView: setting nil to Optional(6F4A197D-5D2D-4DF0-A489-92C0A4F3F879)
CategoryView: getting Optional(6F4A197D-5D2D-4DF0-A489-92C0A4F3F879)
TopListView: getting Optional(399EC22A-F9A5-451D-8A4E-40F0B6F101BB)
TopListView: setting Optional(399EC22A-F9A5-451D-8A4E-40F0B6F101BB) to nil
TopListView: getting nil
TopListView: getting nil
TopListView: getting nil
TopListView: getting nil
TopListView: getting nil
TopListView: getting nil
TopListView: getting nil
You'll see that TopListView
is indeed nil
ing out in the vanilla SwiftUI code. The reason the SwiftUI example above appears to be working just fine is because selected
/selection
don't drive state in any way. In TCA, however, the bindings would send actions to the store that update state, causing the buggy behavior you were seeing. If you attempt to programmatically drive a non-TCA observable object you will see the same behavior.
So the "fix" is to add .navigationViewStyle(StackNavigationViewStyle())
to your navigation view, which changes the binding behavior to not nil
out the parent view's selection. But if you wanna support both iPhone and iPad you'll probably only want to apply this view modifier to the iPhone.
We're hoping that this bug will be fixed next month, but it might be worth filing more feedback with Apple to help make it happen!