Skip to content

Instantly share code, notes, and snippets.

@couchdeveloper
Last active November 14, 2025 09:31
Show Gist options
  • Select an option

  • Save couchdeveloper/43363611f806f5d072b73b699ae1b288 to your computer and use it in GitHub Desktop.

Select an option

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.
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