Reset NavigationStack when changing selection of NavigationSplitView

I'm trying to build an app that has a NavigationSplitView and a NavigationStack in the detail View.

The normal flow works fine, but if I navigate to the second page on the detail view and then select another menu item (i.e. the second item), I'm still on the detail page of the first menu item.

The underlying view of the second detail view changes. This can be observed by the change of the back button label. How do I ensure that my NavigationStack is also reset when I change the selection?

import SwiftUI

enum Option: String, Equatable, Identifiable {
    case first
    case second

    var id: Option { self }
}


struct ContentView: View {
    @State private var selection: Option?

    var body: some View {
        NavigationSplitView {
            List(selection: $selection) {
                NavigationLink(value: Option.first) {
                    Text("First")
                }
                NavigationLink(value: Option.second) {
                    Text("Second")
                }
            }
        } detail: {
            switch selection {
            case .none:
                Text("Please select one option")
            case .some(let wrapped):
                NavigationStack {
                    DetailView(title: wrapped.rawValue)
                }
            }
        }
        .navigationSplitViewStyle(.balanced)
    }
}


struct DetailView: View {
    private var title: String

    init(title: String) {
        self.title = title
    }

    var body: some View {
        List {
            NavigationLink {
                Text(title)
            } label: {
                Text("Show \(title) detail")
            }
        }
        .navigationTitle(title)
    }
}

Accepted Reply

Hi Lars_, thanks for this thorough question, including the gif. This is undefined behavior we weren't aware of, in fact, it behaves as you'd expect on macOS, but each behavior is arguably correct.

I've filed a radar on our end tracking it with this code sample to make this more consistent.

There are a few ways to handle this. You could set up some plumbing and call dismiss() in DetailView when selection changes. But since you're targeting at least iOS 16 (because NavigationSplitView/Stack are first available there), you can get more programmatic control over your navigation state by using value-based NavigationLinks.

private struct DetailView: View {
    private var title: String
    init(title: String) {
        self.title = title
    }

    var body: some View {
        List {
            NavigationLink("Show \(title) detail", value: title)
        }
        .navigationDestination(for: String.self, destination: { title in
            Text(title).font(.headline)
        })
       .navigationTitle(title)
    }
}

Even if no path parameter is provided to a NavigationStack, value-based navigation links will append to an implicit path tracked for you by the Navigation system. Where view-based navigation links will not. When selection changes in the sidebar, the navigation system pops value-based links off the stack.

You'll notice if you use the above example, the stack pop will be animated, which may not be what you want. To disable that, pass a non-animated transaction to the selection binding:

private struct ContentView: View {
    @State private var selection: Option?
    var nonAnimatedTransaction: Transaction {
        var t = Transaction()
        t.disablesAnimations = true
        return t
    }

    var body: some View {
        NavigationSplitView {
            List(selection: $selection.transaction(nonAnimatedTransaction)) {
                NavigationLink(value: Option.first) {
                    Text("First")
                }
                NavigationLink(value: Option.second) {
                    Text("Second")
                }
            }
        } detail: {
            switch selection {
            case .none:
                Text("Please select one option")
            case .some(let wrapped):
                NavigationStack {
                    DetailView(title: wrapped.rawValue)
                }
            }
        }
        .navigationSplitViewStyle(.balanced)
    }
}

For even more control, pass a path argument to NavigationStack and you can manage that completely yourself.

  • Thanks for your response. The solution with the value based navigation links works as expected.

    Interestingly, it also works if you have two separate views, one with the NavigationLink(destination:label:) and one with the .navigationDestination(for:destination:) modifier. It stops working if you have two or more views with the NavigationLink(destination:label:) and navigate between them.

Add a Comment

Replies

Hi Lars_, thanks for this thorough question, including the gif. This is undefined behavior we weren't aware of, in fact, it behaves as you'd expect on macOS, but each behavior is arguably correct.

I've filed a radar on our end tracking it with this code sample to make this more consistent.

There are a few ways to handle this. You could set up some plumbing and call dismiss() in DetailView when selection changes. But since you're targeting at least iOS 16 (because NavigationSplitView/Stack are first available there), you can get more programmatic control over your navigation state by using value-based NavigationLinks.

private struct DetailView: View {
    private var title: String
    init(title: String) {
        self.title = title
    }

    var body: some View {
        List {
            NavigationLink("Show \(title) detail", value: title)
        }
        .navigationDestination(for: String.self, destination: { title in
            Text(title).font(.headline)
        })
       .navigationTitle(title)
    }
}

Even if no path parameter is provided to a NavigationStack, value-based navigation links will append to an implicit path tracked for you by the Navigation system. Where view-based navigation links will not. When selection changes in the sidebar, the navigation system pops value-based links off the stack.

You'll notice if you use the above example, the stack pop will be animated, which may not be what you want. To disable that, pass a non-animated transaction to the selection binding:

private struct ContentView: View {
    @State private var selection: Option?
    var nonAnimatedTransaction: Transaction {
        var t = Transaction()
        t.disablesAnimations = true
        return t
    }

    var body: some View {
        NavigationSplitView {
            List(selection: $selection.transaction(nonAnimatedTransaction)) {
                NavigationLink(value: Option.first) {
                    Text("First")
                }
                NavigationLink(value: Option.second) {
                    Text("Second")
                }
            }
        } detail: {
            switch selection {
            case .none:
                Text("Please select one option")
            case .some(let wrapped):
                NavigationStack {
                    DetailView(title: wrapped.rawValue)
                }
            }
        }
        .navigationSplitViewStyle(.balanced)
    }
}

For even more control, pass a path argument to NavigationStack and you can manage that completely yourself.

  • Thanks for your response. The solution with the value based navigation links works as expected.

    Interestingly, it also works if you have two separate views, one with the NavigationLink(destination:label:) and one with the .navigationDestination(for:destination:) modifier. It stops working if you have two or more views with the NavigationLink(destination:label:) and navigate between them.

Add a Comment