Deep nested navigation

First of all thanks for all the details examples and explanations! I'm battling with nested navigation with a selection that uses programmatic navigation and hit a wall where programmatic navigation is a problem.

I created a gist here which shows pure SwiftUI example. If I start converting it using TCA then it will become a sort of List-NavigateAndLoad example with more nested navigation.

Here is the navigation tree:

  • List view
    --- Item 1
    --- Item 2
    --- Item n
    --------(1. Navigation on each item) Another list or scrollview
    ----------------- Item 1
    ----------------- Item 2
    ----------------- Item m
    -------------------------------(2. Navigation on each item)
    ------------------------------------------- Some details view for 2nd level navigated item.

Problem: When you do 2nd level navigation then set navigation for top or number 1 navigation will call with nil and that's broke everything. Whenever nested navigation is triggering, upper-level navigation getting called but why? I would assume it's a navigation stack and until an item poped, the navigation shouldn't get called.

Please let me know if that's require more explanation but I'm sure I'm not the only one facing that issue.

Thanks

3 Likes

Here are some code snapshots:

struct TopListState: Equatable {
var rows: IdentifiedArrayOf<Row> = [
    .init(value: 1),
    .init(value: 2),
    .init(value: 3),
    .init(value: 4),
    .init(value: 5),
    .init(value: 6),
    .init(value: 7)
]

var selection: Identified<Row.ID, CategoriesState>? <--- For navigation

}

Then in my top list view:

    var body: some View {
    NavigationView {
        WithViewStore(self.store) { viewStore in
            List {
                ForEach(viewStore.rows) { row in
                    NavigationLink(
                        destination: IfLetStore(
                            self.store.scope(
                                state: \.selection?.value,
                                action: TopListAction.categories),
                            then: CategoriesView.init(store:)
                        ),
                        tag: row.id,
                        selection: viewStore.binding(
                            get: \.selection?.id,
                            send: TopListAction.setNavigation(selection:) <--- This will get call for nested navigation
                        )
                    ) {
                        Text("\(row.value)")
                    }
                }
            }.navigationBarTitle("Top List")
        }
    }
}

Here is a child list cell navigation link:

                NavigationLink( <---- When that navigation get pushed, the upper navigation get popped and 
                destination: CategoryListView(data: self.category.data),
                tag: self.category.id,
                selection: self.selection
            ) {
                Text("See All".uppercased())
                    .font(.headline)
            }

Here is a working version using Playground

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 niling 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!

4 Likes

Also worth noting that you can rewrite the above SwiftUI example and remove all the selection bindings and everything will still work just fine, since nothing is mutating them directly for programmatic navigation. And you can do the same in TCA when you don't need programmatic navigation: merely omit the selection bindings and let SwiftUI do its thing.

If you do need programmatic navigation, the selection bindings are there for you, but you will be dealing with this bug (and others).

Thanks a lot @stephencelis I know that's not TCA bug and it's SwiftUI issue but didn't know that issue brought up before. I also know I'm not the only one seeing it but is there any workaround which you gave here and thanks a lot for that.

To verify the issue is actually from SwiftUI, I wrote the whole SwiftUI from TCA version and got same problem.

Again, thanks for the help, that solve my problem. I'll try to find a way to report bug to apple.

Ah okay, my bad for misunderstanding! Glad this fixes things for you.

One other bug you may need to be aware of and workaround is deep-linking. It's not possible to deep link more than one layer right now all at once. You must incrementally drill down each layer, which can be tricky in both vanilla SwiftUI and TCA.

One other bug you may need to be aware of and workaround is deep-linking. It's not possible to deep link more than one layer right now all at once. You must incrementally drill down each layer, which can be tricky in both vanilla SwiftUI and TCA.

Thanks a lot for the heads up! Lots of issue need to be fixed from SwiftUI.

@stephencelis In my case, even when using .navigationViewStyle(StackNavigationViewStyle()), the programmatic navigation doesn't work more than one layer deep. Is that what you experienced in your testing as well?

I have two Lists, both using NavigationLink(destination:tag:selection:), in this composition: List 1 -> List 2 -> Item Detail.

I need to use programmatic navigation because I offer item deletion from both List 2 and Item Detail levels, which should automatically dismiss the presented item by setting the "selectedItem" on the store to nil. The dismissal works on the first (List 2) level, but not on the Item Detail level.

I'm guessing this is again the SwiftUI issue, but I wanted to ask you anyway. Thanks!

Yup, unfortunately :slightly_frowning_face:

Hopefully we'll get some fixes in the coming weeks, though!

1 Like

Thanks Stephen. Yeah, let's cross our fingers.

@stephencelis I found another SwiftUI bug related with that. Submitted a bug


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
    })
  }
}

struct AppView: View {
    var body: some View {
        TabView {
            NavigationView {
                ContentView()
            }
            .tag(1)
            .tabItem({ Text("One") })
            .navigationViewStyle(StackNavigationViewStyle())
            
            NavigationView {
                ContentView()
            }
            .tag(2)
            .tabItem({ Text("Two") })
            .navigationViewStyle(StackNavigationViewStyle())

        }
    }
}

struct ContentView: View {
    @State var isActive: Bool = false
    
    var body: some View {
        VStack {
            Text("Hello")
        }
        .navigationBarTitle(Text("Title"), displayMode: .inline)
        .navigationBarItems(
            trailing: NavigationLink(
                destination: NestedView(),
                isActive: self.$isActive.debug("isActive"),
                label: {
                    Text("Add")
                }
            )
        )
    }
}

struct NestedView: View {
    @State var isPresenting: Bool = false
    var body: some View {
        ScrollView {
            HStack {
                Button(action: {
                    self.isPresenting = true
                }){
                    Text("Button 1")
                }
                
                Button(action: {
                    self.isPresenting = true
                }){
                    Text("Button 2")
                }
            }
        }.sheet(isPresented: self.$isPresenting.debug("Presenting")) {
            Text("Presnting")
        }
    }
}

print("✅")

import PlaygroundSupport

PlaygroundPage.current.liveView = UIHostingController(rootView: AppView())

I hope they will really fix the Navigation properly

1 Like

While StackNavigationViewStyle does solve a lot of navigation issues with SwiftUI, I'm experiencing that it also causes rows in the second-level List to remain highlighted after tapping the "Back" button.

Here's a sample code that reproduces it:

struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                NavigationLink(destination: ItemCollectionView()) {
                    Text("Navigate to level 2")
                }
            }
        }
        // BUG: This line causes the "Navigate to level 3" row to remain highlighted
        .navigationViewStyle(StackNavigationViewStyle())
    }
}

struct ItemCollectionView: View {
    var body: some View {
        List {
            NavigationLink(destination: Text("Level 3")) {
                Text("Navigate to level 3")
            }
        }
    }
}

I know this is purely a SwiftUI bug, so I filled feedback, but I would still love to know if anyone has a workaround? Thanks!

I solved this by moving the innermost NavigationLink out of the list and setting the label parameter to EmptyView()