Instantly share code, notes, and snippets.
Last active
November 14, 2025 09:31
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save couchdeveloper/43363611f806f5d072b73b699ae1b288 to your computer and use it in GitHub Desktop.
Demonstrates how to use a NavigationSplitView with a detail view with a NavigationStack, where the navigation path is retained when changing to different details.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import SwiftUI | |
| enum Main { | |
| enum Views {} | |
| enum Models {} | |
| } | |
| extension Main.Models { | |
| // The data value rendered in the Sidebar view | |
| struct Item: Identifiable { | |
| var id: UUID | |
| var text: String | |
| var detail: Detail? | |
| init(id: UUID = UUID(), text: String, detail: Detail? = nil) { | |
| self.id = id | |
| self.text = text | |
| self.detail = detail | |
| } | |
| } | |
| // The data rendered in the Detail view | |
| // Note: values will be used as path elements in a navigation | |
| // stack. Thus these values need to conform to Hashable. | |
| struct Detail: Identifiable, Hashable, Equatable { | |
| var id: UUID | |
| var text: String | |
| init(id: UUID = UUID(), _ text: String) { | |
| self.id = id | |
| self.text = text | |
| } | |
| static func == (lhs: Self, rhs: Self) -> Bool { | |
| lhs.id == rhs.id | |
| } | |
| func hash(into hasher: inout Hasher) { | |
| hasher.combine(id) | |
| } | |
| } | |
| } | |
| extension Main.Views { | |
| typealias Item = Main.Models.Item | |
| typealias Detail = Main.Models.Detail | |
| public struct ContentView: View { | |
| @State private var items: [Item] = [ | |
| .init(text: "Zero"), | |
| .init(text: "One", detail: .init("Detail One")), | |
| .init(text: "Two", detail: .init("Detail Two")), | |
| .init(text: "Three", detail: .init("Detail Three")), | |
| ] | |
| public var body: some View { | |
| MainView(items: $items) | |
| } | |
| } | |
| // The view which handles the navigation. It provides the selection | |
| // for the sidebar and the paths for the navigation stack for each detail | |
| // item (individually) - which it also takes full control over it. The | |
| // NavigationSplitView cannot mutate the navigation path for any detail. | |
| // Note, that in this use case, the detail view does NOT share a single | |
| // NavigationStack - instead, for each detail it creates a new one. This | |
| // is crucial for this use case, since it is required to remember the | |
| // navigation path for each individual detail. If we would share a single | |
| // NavigationStack, the NavigationSplitView would reset the NavigationStack | |
| // each time it renderes a different detail item, and thus, the navigation | |
| // path would be cleared. | |
| struct MainView: View { | |
| @Binding var items: [Item] | |
| @State private var sideBarSelection: Item.ID? = nil | |
| @State private var detailPaths: Dictionary<Item.ID, [Detail]> = [:] | |
| var body: some View { | |
| NavigationSplitView { | |
| SidebarView(items: items, selection: $sideBarSelection) | |
| } detail: { | |
| let item = items.first(where: { $0.id == sideBarSelection }) | |
| if let item { | |
| DetailView( | |
| detail: item.detail, | |
| path: detailPaths[item.id, default: []], | |
| pushDetail: { detail in | |
| push(detail: detail, forKey: item.id) | |
| }, | |
| popDetail: { | |
| popDetail(forKey: item.id) | |
| } | |
| ) | |
| } else { | |
| Text("No item selected") | |
| } | |
| } | |
| .task { | |
| sideBarSelection = items.first?.id | |
| } | |
| } | |
| private func popDetail(forKey key: UUID) { | |
| assert(detailPaths[key] != nil) | |
| detailPaths[key]?.removeLast() | |
| } | |
| private func push(detail: Detail, forKey key: UUID) { | |
| detailPaths[key, default: []].append(detail) | |
| } | |
| } | |
| struct SidebarView: View { | |
| let items: [Item] | |
| let selection: Binding<Item.ID?> | |
| var body: some View { | |
| List(items, selection: selection) { item in | |
| Text("\(item.text) \(item.id.uuidString.suffix(4))") | |
| } | |
| } | |
| } | |
| struct DetailView: View { | |
| let detail: Detail? | |
| let path: [Detail] | |
| let pushDetail: (Detail) -> Void | |
| let popDetail: () -> Void | |
| var body: some View { | |
| // Important: we do not use a writeble binding here, since we don't | |
| // want the path mutated by the system component itself. Instead, | |
| // we want full control over it. | |
| NavigationStack(path: .constant(path)) { | |
| DetailContentView(detail: detail, pushDetail: pushDetail) | |
| .navigationDestination(for: Detail.self) { detail in | |
| VStack { | |
| Text("Path: \(path.map { $0.id.uuidString.suffix(4) }.joined(separator: ", "))") | |
| DetailContentView(detail: detail, pushDetail: pushDetail) | |
| } | |
| .navigationBarBackButtonHidden(true) | |
| .toolbar { | |
| ToolbarItem(placement: .navigation) { | |
| BackButton { | |
| print("*** back button tapped") | |
| // pop view from stack | |
| popDetail() | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Important: keep a separate navigation stack for each | |
| // detail: | |
| .id(detail?.id ?? UUID()) | |
| } | |
| } | |
| struct DetailContentView: View { | |
| let detail: Detail? | |
| let pushDetail: (Detail) -> Void | |
| var body: some View { | |
| VStack { | |
| switch detail { | |
| case .none: | |
| Text("No details available") | |
| .navigationTitle("Details") | |
| case .some(let detail): | |
| Text(detail.text) | |
| .navigationTitle(detail.text) | |
| // Do not use a NavigationLink here, since we | |
| // manually handle the push action: | |
| #if true | |
| Button("Show more details") { | |
| pushDetail(Detail(detail.text + ".more")) | |
| } | |
| .buttonStyle(.borderless) | |
| #else | |
| NavigationLink("Show more details", value: Detail(detail.text + ".more")) | |
| #endif | |
| } | |
| } | |
| } | |
| } | |
| } | |
| #Preview { | |
| Main.Views.ContentView() | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment