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)
    }
}
Answered by Frameworks Engineer in 741498022

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.

Accepted Answer

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.

When selection changes in the sidebar, the navigation system pops value-based links off the stack.

I think I'm seeing this in my macOS Swift app but in my case it's not the behavior I want. I have navigation links in the sidebar and various views in the detail view. But I want to preserve the state of each detail view so that when the user navigates back to a particular view the navigation path and scroll state is preserved. But the system seems to be resetting the state of each detail view even if I control the navigation paths myself. Is there a way to disable this behavior?

Reset NavigationStack when changing selection of NavigationSplitView
 
 
Q