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?
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 Button
s, 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]
}
}
}