A minimal SwiftUI sample showing how to drive a NavigationSplitView with a
small navigation router, supporting both three-column (sidebar + list +
detail) and two-column (sidebar + full-width detail) layouts from the same
state.
For this pattern I took inspiration from NavigationPath routing tutorial by Natascha Fadeeva.
It accompanies the blog post “A NavigationRouter for SwiftUI’s NavigationSplitView”. The app is themed around a tiny offline library of books and authors — no networking, no dependencies, all data is in memory.
Targets macOS and iPadOS (the regular, multi-column size class). There is intentionally no separate compact / iPhone layout —
NavigationSplitViewcollapses to a stack on its own when space is tight.
SwiftUI gives you NavigationSplitView and NavigationStack, but wiring them
together gets messy when navigation logic is scattered across views via
NavigationLink. This sample centralises three things:
Every place the app can show is a single enum case. Associated values carry
the payload (.author(Author), .book(Book)). Because those payloads are
Hashable, Swift synthesises Hashable/Equatable for free — no hand-written
==/hash(into:).
Each case also carries its own metadata: numberOfColumns, displayName,
iconName.
See NavigationItem.swift.
An @Observable @MainActor class holding contentRoot (middle column),
detailRoot (detail column root) and detailStack (the pushed stack). Views
never poke a NavigationStack path directly; they call setContentRoot,
setDetailRoot, or push.
It also hosts the single view(for:) factory that maps any NavigationItem to
its view, so a destination renders identically no matter how it was reached.
The host reads router.detailRoot.numberOfColumns and chooses a three-column or
two-column NavigationSplitView. The detail column is the same in both: a
NavigationStack rooted at detailRoot, pushing onto detailStack.
Sidebar selection ──► router.setContentRoot(item)
• sets contentRoot (middle list)
• resets detailRoot to a placeholder
• 2-column items (Settings/About) skip the middle list
Row in middle list ──► router.setDetailRoot(.book(book)) // replaces detail root
Link inside detail ──► router.push(.author(author)) // pushes on the stack
BooksNavigation/
├── BooksNavigationApp.swift // @main, builds Library + Router
├── Model/
│ └── Library.swift // value types + in-memory sample data
├── Navigation/
│ ├── NavigationItem.swift // the navigation vocabulary
│ ├── NavigationRouter.swift // state + intent + view factory
│ ├── RegularContentView.swift // the NavigationSplitView host
│ └── SidebarView.swift // first column
└── Views/ // leaf screens (lists + detail)
Open BooksNavigation.xcodeproj in Xcode 16 or later and run on My Mac or an
iPad simulator.
MIT — do whatever you like with it.