| name | swiftui-navigation-architecture |
| description | Default navigation shape for SwiftUI Apps (iOS 18 / macOS 15, Swift 6) โ value-based `NavigationStack(path:)` over a typed `Route` enum, one `@Observable @MainActor` Router in `.environment`, `navigationDestination(for:)` at the stack root (never in a lazy container), per-transition presentation semantics (push / sheet / `fullScreenCover` / popover / alert / root-swap) incl. macOS behavior (no native `fullScreenCover` โ push fallback, pop-to-landing), `item:`-driven modal optionals, `.onOpenURL` deep-link funnel, `NavigationSplitView`, per-tab paths, `Codable` restoration. Invoke when wiring an App's navigation, choosing sheet vs cover vs push, adding deep links / restoration, migrating off `NavigationView`, or asked "router / coordinator in SwiftUI". Bugs โ swiftui-interaction-footguns. |
SwiftUI Navigation Architecture
The default navigation shape for Apps on this catalog's baseline ([[apple-platform-targets]]: iOS 18 / macOS 15, Swift 6 language mode): navigation state is data โ a typed route enum in one observable router โ so flows are unit-testable (assert on [Route]), deep-linkable (URL โ routes is a pure function), and restorable (routes are Codable).
When to invoke
- Wiring navigation in a new App target, or adding a second entry point (deep link, widget tap, notification, tab)
- Adding deep links / universal links or state restoration to an existing App
- Migrating off
NavigationView or view-payload NavigationLink(destination:)
- Asked "how do I do a router / coordinator in SwiftUI?"
Scope
Owns container choice (Stack / SplitView / Tab), route modeling, the router object, deep-link funneling, and restoration. Does NOT own: known interaction bugs in nav components โ [[swiftui-interaction-footguns]]; injecting the services destination views need โ [[swift-dependency-injection]]; nav-chrome accessibility โ [[ios-accessibility-engineering]].
Pick the container
| App shape | Container |
|---|
| Single drill-down flow (iPhone-first) | NavigationStack(path:) |
| 2โ5 top-level peer sections | TabView + one NavigationStack per tab, each with its own path |
| Source list โ detail (iPad / Mac) | NavigationSplitView; sidebar selection is router state, the detail column hosts its own NavigationStack |
Never NavigationView in new code โ deprecated since iOS 16.
The default shape
enum Route: Hashable, Codable {
case board(id: UUID)
case settings
}
enum Modal: String, Identifiable {
case paywall, onboarding
var id: String { rawValue }
}
@Observable @MainActor
final class Router {
var path: [Route] = []
var modal: Modal?
func open(_ url: URL) {
guard url.scheme == "myapp" else { return }
switch url.host {
case "board": path = [.board(id: UUID(uuidString: url.lastPathComponent) ?? UUID())]
case "settings": path = [.settings]
default: break
}
}
}
@main struct MyApp: App {
@State private var router = Router()
var body: some Scene {
WindowGroup {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: Route.self) { route in
switch route {
case .board(let id): BoardView(id: id)
case .settings: SettingsView()
}
}
}
.environment(router)
.sheet(item: $router.modal) { ModalHost($0) }
.onOpenURL { router.open($0) }
}
}
}
Rules the shape encodes:
- Push with values โ
NavigationLink(value:) or router.path.append(...); never NavigationLink(destination:) in a path-managed stack (those pushes bypass path, desyncing back-stack, deep links, and restoration).
navigationDestination(for:) on the stack's root content, outside lazy containers. Apple's docs: "Do not put a navigation destination modifier inside a 'lazy' container, like List or LazyVStack. โฆ Add the navigation destination modifier outside these containers so that the navigation stack can always see the destination."
- Typed
[Route] over NavigationPath โ pattern-matchable, exhaustively switched, Codable for free.
- Modal โ push โ presented flows hang off router optionals (
item:-driven; one optional per presentation kind โ sheet, cover, alert. Parallel isPresented: Bools race and can present blank); a presented flow is never a Route case. Which kind โ next section.
- Router is
@MainActor (it is UI state; Swift 6 enforces it), routes are Hashable + Codable value types.
Presentation semantics (decide per transition โ iOS and macOS)
"Where does the user land when this closes?" is decided by the semantic you pick, not by the destination view. Pick the tag first; the API and its back/dismiss behavior follow.
| Semantic | API | Dismissal | iOS / iPadOS | macOS |
|---|
| push | NavigationStack + navigationDestination | back pops one level; edge-swipe on by default | โ | same model, toolbar back |
| sheet | .sheet(item:) (+ .presentationDetents) | swipe-to-dismiss on by default; interactiveDismissDisabled(true) to force completion | detents resize; >1 detent shows the grabber | window-styled sheet โ detents don't drive sizing, size the content itself |
| full-screen cover | .fullScreenCover(item:) | no interactive dismiss; exit only via explicit close | opaque, covers everything | unavailable on native macOS (Mac Catalyst only) โ fall back to push/sheet, see below |
| popover | .popover | tap outside | collapses to a sheet in compact width unless .presentationCompactAdaptation(.popover) | always a true anchored popover |
| alert | .alert(_:isPresented:presenting:) | button tap only โ no scrim tap, no swipe | data-drive off one optional | same |
| dialog | .confirmationDialog | Cancel or tap outside | bottom action sheet | rendered alert-style |
| root-swap | conditional root content on @Observable state | none โ the outgoing tree is destroyed | auth gates, onboarding-done | same |
Two contract rules the table enforces:
- "Closes on outside tap" is a semantic, not a style โ if that's the requirement, it's a
confirmationDialog or popover, never an .alert, regardless of visual intent.
- Login/logout is root-swap, not a modal. Conditionally render
LoginView vs AppView at the root off auth state: success flips the flag and the login tree is destroyed; logout flips it back and tears down the entire app stack (paths, sheets, all of it). Presenting the app over login couples app lifetime to a presentation and turns logout into "dismiss a modal" with login still alive underneath.
macOS fallback for full-screen flows
A flow covered by fullScreenCover on iOS ships as a push (or sheet) on macOS โ and Close must land on the same screen on both platforms. The trap: dismiss() closes the whole cover, but the pushed variant's naive path.removeLast() pops one level and strands the user mid-flow.
extension Router {
func startGame(id: UUID) {
#if os(macOS)
path.append(.board(id: id))
#else
cover = .board(id: id)
#endif
}
func closeGame() {
#if os(macOS)
path.removeAll()
#else
cover = nil
#endif
}
}
Route both entry and exit through router methods (as above) so no view ever encodes the platform branch.
Deep links & restoration
- Every URL entry point (
onOpenURL, universal links, NSUserActivity, notification taps) funnels into router.open(_:) at the scene root โ one handler, one mapping function, unit tests assert URL โ [Route] directly.
- Restoration: on
scenePhase == .background, JSONEncoder the [Route] into @SceneStorage(a String slot); on launch, decode and assign. Routes that reference store objects carry IDs โ resolve at display time and drop routes that no longer resolve instead of crashing.
Multi-column & tabs
NavigationSplitView: sidebar selection lives in the router; only the detail column hosts a NavigationStack. Don't nest stacks in the sidebar.
TabView: the router owns selectedTab and one path per tab โ paths kept in per-view @State reset whenever tab identity churns. Selecting the already-active tab popping to root becomes a 2-line router method.
- iPad / Mac footguns in these containers (sizeClass on Mac, inert sidebar Labels) โ [[swiftui-interaction-footguns]].
Rationale
- Navigation state as data: view-payload links hide "where is the user" inside the view tree; a typed path makes it assertable, loggable, and reproducible from a URL or a saved session.
- One router in
.environment: a single owner instead of bindings threaded through N view layers; @Observable keeps invalidation scoped to views that actually read path.
- Typed enum over
NavigationPath: exhaustive switch in navigationDestination means the compiler flags every unhandled route; NavigationPath trades that away for type erasure most single-module apps don't need.
Deviation considerations
- Heterogeneous route types across feature packages that genuinely can't share one enum โ
NavigationPath + its CodableRepresentation for restoration; you give up exhaustive matching.
- A 2-screen utility โ a bare
NavigationStack without router or path is fine; adopt the shape when the second entry point appears, not speculatively.
- UIKit-hosted hybrids (heavy
UIViewController interop) โ keep coordination at the UIKit layer; don't force a SwiftUI router across the hosting bridge.
- iOS 17 floor โ the shape works unchanged (
@Observable, NavigationStack both available); below that, ObservableObject replaces @Observable.
- Mac Catalyst โ
fullScreenCover is available there; the push/sheet fallback is for native (AppKit-based) macOS targets.
Common Mistakes
NavigationView in new code โ deprecated since iOS 16, unpredictable column behavior. Use NavigationStack / NavigationSplitView.
navigationDestination(for:) inside List / LazyVStack โ the lazy container may not have created the registering view yet, so pushes silently fail (runtime console warning). Register at the stack root.
- Mixing
NavigationLink(destination:) into a path-based stack โ pushes invisible to path; back-stack count, deep links, and restoration all desync.
- Sheets modeled as pushed routes โ back-button vs dismiss semantics conflict; keep a separate
Modal enum.
- Per-tab paths in view
@State โ switching tabs (or any identity churn) resets the stack; paths belong to the router.
onOpenURL sprinkled across views โ multiple competing handlers; one scene-root handler feeding one mapping function.
- Router not
@MainActor โ Swift 6 isolation errors the first time a Task mutates path; annotate the class, not call sites.
- Model objects as route payloads โ bloats
Hashable/Codable, goes stale after edits; carry IDs and resolve at display.
@Environment(\.dismiss) read in the presenter โ it only dismisses when read inside the presented content; in the presenter it's a no-op. Presenter-side closing = nil out the router optional.
- Shared Close across an iOS cover / macOS push split that pops one level โ on macOS the user lands mid-flow instead of on the hub; branch close to pop-to-landing (see the macOS fallback).
- Relying on automatic dismiss cascades across stacked presentations โ model
dismiss() vs dismissAll() as distinct router methods; chain a follow-up presentation in the prior one's onDismiss, never present-while-presenting.
Review Checklist
Related skills
- [[swiftui-interaction-footguns]] โ known bugs in the nav components this skill wires together
- [[swift-dependency-injection]] โ how destination views get their services
- [[apple-platform-targets]] โ the iOS 18 / macOS 15 baseline this shape assumes
- [[ios-accessibility-engineering]] โ accessibility of the navigation chrome