Search code examples
macosswiftui

Preserving navigation state in a macOS SwiftUI application with sidebar navigation


I have a simple NavigationSplitView with various detail views and I want to preserve the navigation state in each of these detail views when the user switches detail views. But it seems that the navigation path is always reset when switching views.

For example. Try this and navigate a few times when the left sidebar is on the first item. Then switch to the second item. Then back to the first item. The state of the detail view is always reset instead of being preserved. It doesn't work if I let SwiftUI manage the navigation paths instead of providing them myself either.

import SwiftUI

enum SelectedView {
    case first, second
}

enum DetailItem: String, CaseIterable, Hashable {
    case one, two, three
}

struct DetailView: View {
    let label: String

    var body: some View {
        VStack {
            Text(label)

            ForEach(DetailItem.allCases, id: \.self) { item in
                NavigationLink(item.rawValue, value: item)
            }
        }
        .navigationTitle(label)
    }
}

struct ContentView: View {
    @State private var selectedItem = SelectedView.first
    @State private var firstPath = NavigationPath()
    @State private var secondPath = NavigationPath()

    var body: some View {
        NavigationSplitView {
            List(selection: $selectedItem) {
                Text("First")
                    .tag(SelectedView.first)
                Text("Second")
                    .tag(SelectedView.second)
            }
        } detail: {
            ZStack {
                NavigationStack(path: $firstPath) {
                    DetailView(label: "First Start")
                        .opacity(selectedItem == .first ? 1 : 0)
                        .navigationDestination(for: DetailItem.self) { item in
                            DetailView(label: item.rawValue)
                        }
                }
                NavigationStack(path: $secondPath) {
                    DetailView(label: "Second Start")
                        .opacity(selectedItem == .second ? 1 : 0)
                        .navigationDestination(for: DetailItem.self) { item in
                            DetailView(label: item.rawValue)
                        }
                }
            }
        }
    }
}

I'm sure there must be some way to preserve the navigation state for each of the top-level NavigationStacks right?


Solution

  • The navigation paths only get reset when there is a selectable List inside the side bar. It does preserve the navigation paths if you control which view is selected using simple Buttons, without a List. This suggests that this behaviour might be by-design.

    So the solution is just don't use a selectable List, or not use a List at all. Reinvent the selection yourself.

    Here, I've used a non-selectable List and tried to recreate the appearance of a list selection:

    struct ContentView: View {
        @State private var selectedItem = SelectedView.first
        @State private var firstPath = NavigationPath()
        @State private var secondPath = NavigationPath()
    
        var body: some View {
            NavigationSplitView {
                List {
                    Text("First")
                        .padding(5)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .contentShape(Rectangle())
                        .onTapGesture {
                            selectedItem = .first
                        }
                        .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
                        .background(selectedItem == .first ? Color.accentColor : Color.clear, in: RoundedRectangle(cornerRadius: 5))
                    Text("Second")
                        .padding(5)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .contentShape(Rectangle())
                        .onTapGesture {
                            selectedItem = .second
                        }
                        .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
                        .background(selectedItem == .second ? Color.accentColor : Color.clear, in: RoundedRectangle(cornerRadius: 5))
                }
            } detail: {
                NavigationStack(path: selectedItem == .first ? $firstPath : $secondPath) {
                    DetailView(label: "\(selectedItem == .first ? "First" : "Second") Start")
                        .navigationDestination(for: DetailItem.self) { item in
                            DetailView(label: item.rawValue)
                        }
                }
            }
        }
    }
    

    Instead of a List, you can also wrap with a ScrollView { LazyVStack { ... } }.


    Also consider encapsulating the view and navigation path into one single object:

    @Observable class SelectedView: Identifiable {
        let name: String
        var path = NavigationPath()
        
        init(name: String) {
            self.name = name
        }
    }
    
    struct ContentView: View {
        @State private var views = [SelectedView(name: "first"), SelectedView(name: "second")]
        @State private var selectedItem: SelectedView?
    
        var body: some View {
            NavigationSplitView {
                List(views) { view in
                    Text(view.name)
                        .padding(5)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .contentShape(Rectangle())
                        .onTapGesture {
                            selectedItem = view
                        }
                        .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
                        .background(selectedItem?.id == view.id ? Color.accentColor : Color.clear, in: RoundedRectangle(cornerRadius: 5))
                }
            } detail: {
                if let selectedItemBinding = Binding($selectedItem) {
                    NavigationStack(path: selectedItemBinding.path) {
                        DetailView(label: "Second Start")
                            .navigationDestination(for: DetailItem.self) { item in
                                DetailView(label: item.rawValue)
                            }
                    }
                }
            }
            .onAppear {
                selectedItem = views[0]
            }
        }
    }