axiom-swiftui-nav-ref by CharlesWiltgen
Reference — Comprehensive SwiftUI navigation guide covering NavigationStack (iOS 16+), NavigationSplitView (iOS 16+), NavigationPath, deep linking, state restoration, Tab+Navigation integration (iOS 18+), Liquid Glass navigation (iOS 26+), and coordinator patterns
Content & Writing
238 Stars
16 Forks
Updated Jan 16, 2026, 03:16 PM
Why Use This
This skill provides specialized capabilities for CharlesWiltgen's codebase.
Use Cases
- Developing new features in the CharlesWiltgen repository
- Refactoring existing code to follow CharlesWiltgen standards
- Understanding and working with CharlesWiltgen's codebase structure
Install Guide
2 steps- 1
Skip this step if Ananke is already installed.
- 2
Skill Snapshot
Auto scan of skill assets. Informational only.
Valid SKILL.md
Checks against SKILL.md specification
Source & Community
Skill Stats
SKILL.md 968 Lines
Total Files 1
Total Size 0 B
License MIT
---
name: axiom-swiftui-nav-ref
description: Reference — Comprehensive SwiftUI navigation guide covering NavigationStack (iOS 16+), NavigationSplitView (iOS 16+), NavigationPath, deep linking, state restoration, Tab+Navigation integration (iOS 18+), Liquid Glass navigation (iOS 26+), and coordinator patterns
license: MIT
compatibility: iOS 16+ (NavigationStack), iOS 18+ (Tab/Sidebar), iOS 26+ (Liquid Glass)
metadata:
version: "1.0.0"
last-updated: "2025-12-05"
---
# SwiftUI Navigation API Reference
## Overview
SwiftUI's navigation APIs provide data-driven, programmatic navigation that scales from simple stacks to complex multi-column layouts. Introduced in iOS 16 (2022) with NavigationStack and NavigationSplitView, evolved in iOS 18 (2024) with Tab/Sidebar unification, and refined in iOS 26 (2025) with Liquid Glass design.
#### Evolution timeline
- **2022 (iOS 16)** NavigationStack, NavigationSplitView, NavigationPath, value-based NavigationLink
- **2024 (iOS 18)** Tab/Sidebar unification, sidebarAdaptable style, zoom navigation transition
- **2025 (iOS 26)** Liquid Glass navigation chrome, bottom-aligned search, floating tab bars, backgroundExtensionEffect
#### Key capabilities
- **Data-driven navigation** NavigationPath represents stack state, enabling programmatic push/pop and deep linking
- **Multi-column layouts** NavigationSplitView adapts automatically (3-column on iPad → single stack on iPhone)
- **State restoration** Codable NavigationPath + SceneStorage for persistence across app launches
- **Tab integration** Per-tab NavigationStack with state preservation on tab switch (iOS 18+)
- **Liquid Glass** Automatic glass navigation bars, sidebars, and toolbars (iOS 26+)
#### When to use vs UIKit
- **SwiftUI navigation** New apps, multiplatform, simpler navigation flows → Use NavigationStack/SplitView
- **UINavigationController** Complex coordinator patterns, legacy code, specific UIKit features → Consider UIKit
#### Related Skills
- Use `axiom-swiftui-nav` for anti-patterns, decision trees, pressure scenarios
- Use `axiom-swiftui-nav-diag` for systematic troubleshooting of navigation issues
---
## When to Use This Skill
Use this skill when:
- **Implementing navigation APIs** NavigationStack, NavigationSplitView, NavigationPath, Tab+Navigation
- **Deep linking or state restoration** URL routing, Codable NavigationPath, SceneStorage
- **Adopting iOS 26+ features** Liquid Glass navigation, bottom-aligned search, tab bar minimization
- **Choosing navigation architecture** Stack vs SplitView vs coordinator patterns
---
## API Evolution
### Timeline
| Year | iOS Version | Key Features |
|------|-------------|--------------|
| 2020 | iOS 14 | NavigationView (deprecated iOS 16) |
| 2022 | iOS 16 | NavigationStack, NavigationSplitView, NavigationPath, value-based NavigationLink |
| 2024 | iOS 18 | Tab/Sidebar unification, sidebarAdaptable, TabSection, zoom transitions |
| 2025 | iOS 26 | Liquid Glass navigation, backgroundExtensionEffect, tabBarMinimizeBehavior |
### NavigationView (Deprecated)
NavigationView is deprecated as of iOS 16. Use NavigationStack (single-column push/pop) or NavigationSplitView (multi-column) exclusively in new code. Key improvements: single NavigationPath replaces per-link `isActive` bindings, value-based type safety, built-in Codable state restoration. See "Migrating to new navigation types" documentation.
---
## NavigationStack Complete Reference
NavigationStack represents a push-pop interface like Settings on iPhone or System Settings on macOS.
### 1.1 Creating NavigationStack
#### Basic NavigationStack
```swift
NavigationStack {
List(Category.allCases) { category in
NavigationLink(category.name, value: category)
}
.navigationTitle("Categories")
.navigationDestination(for: Category.self) { category in
CategoryDetail(category: category)
}
}
```
#### With Path Binding (WWDC 2022, 6:05)
```swift
struct PushableStack: View {
@State private var path: [Recipe] = []
@StateObject private var dataModel = DataModel()
var body: some View {
NavigationStack(path: $path) {
List(Category.allCases) { category in
Section(category.localizedName) {
ForEach(dataModel.recipes(in: category)) { recipe in
NavigationLink(recipe.name, value: recipe)
}
}
}
.navigationTitle("Categories")
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
}
.environmentObject(dataModel)
}
}
```
Path binding + value-presenting NavigationLink + `navigationDestination(for:)` form the core data-driven navigation pattern.
### 1.2 NavigationLink (Value-Based)
#### Value-presenting NavigationLink
```swift
// Correct: Value-based (iOS 16+)
NavigationLink(recipe.name, value: recipe)
// Correct: With custom label
NavigationLink(value: recipe) {
RecipeTile(recipe: recipe)
}
// Deprecated: View-based (iOS 13-15)
NavigationLink(recipe.name) {
RecipeDetail(recipe: recipe) // Don't use in new code
}
```
#### How NavigationLink works with NavigationStack
1. NavigationStack maintains a `path` collection
2. Tapping a value-presenting link appends the value to the path
3. NavigationStack maps `navigationDestination` modifiers over path values
4. Views are pushed onto the stack based on destination mappings
### 1.3 navigationDestination Modifier
#### Single Type
```swift
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
```
#### Multiple Types
```swift
NavigationStack(path: $path) {
RootView()
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
.navigationDestination(for: Category.self) { category in
CategoryList(category: category)
}
.navigationDestination(for: Chef.self) { chef in
ChefProfile(chef: chef)
}
}
```
#### Placement rules
- Place `navigationDestination` outside lazy containers (not inside ForEach)
- Place near related NavigationLinks for code organization
- Must be inside NavigationStack hierarchy
```swift
// Correct: Outside lazy container
ScrollView {
LazyVGrid(columns: columns) {
ForEach(recipes) { recipe in
NavigationLink(value: recipe) {
RecipeTile(recipe: recipe)
}
}
}
}
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
// Wrong: Inside ForEach (may not be loaded)
ForEach(recipes) { recipe in
NavigationLink(value: recipe) { RecipeTile(recipe: recipe) }
.navigationDestination(for: Recipe.self) { r in // Don't do this
RecipeDetail(recipe: r)
}
}
```
### 1.4 NavigationPath
NavigationPath is a type-erased collection for heterogeneous navigation stacks.
#### Typed Array vs NavigationPath
```swift
// Typed array: All values same type
@State private var path: [Recipe] = []
// NavigationPath: Mixed types
@State private var path = NavigationPath()
```
#### NavigationPath Operations
```swift
// Append value
path.append(recipe)
// Pop to previous
path.removeLast()
// Pop to root
path.removeLast(path.count)
// or
path = NavigationPath()
// Check count
if path.count > 0 { ... }
// Deep link: Set multiple values
path.append(category)
path.append(recipe)
```
#### Codable Support
```swift
// NavigationPath is Codable when all values are Codable
@State private var path = NavigationPath()
// Encode
let data = try JSONEncoder().encode(path.codable)
// Decode
let codableRep = try JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data)
path = NavigationPath(codableRep)
```
---
## NavigationSplitView Complete Reference
NavigationSplitView creates multi-column layouts that adapt to device size.
### 2.1 Two-Column Layout
#### Basic Two-Column (WWDC 2022, 10:40)
```swift
struct MultipleColumns: View {
@State private var selectedCategory: Category?
@State private var selectedRecipe: Recipe?
@StateObject private var dataModel = DataModel()
var body: some View {
NavigationSplitView {
List(Category.allCases, selection: $selectedCategory) { category in
NavigationLink(category.localizedName, value: category)
}
.navigationTitle("Categories")
} detail: {
if let recipe = selectedRecipe {
RecipeDetail(recipe: recipe)
} else {
Text("Select a recipe")
}
}
}
}
```
### 2.2 Three-Column Layout
Add a `content:` closure between sidebar and detail for a middle column:
```swift
NavigationSplitView {
List(Category.allCases, selection: $selectedCategory) { category in
NavigationLink(category.localizedName, value: category)
}
} content: {
List(dataModel.recipes(in: selectedCategory), selection: $selectedRecipe) { recipe in
NavigationLink(recipe.name, value: recipe)
}
} detail: {
RecipeDetail(recipe: selectedRecipe)
}
```
### 2.3 NavigationSplitView with NavigationStack
Place a `NavigationStack(path:)` inside the detail column for grid-to-detail drill-down while preserving sidebar selection:
```swift
NavigationSplitView {
List(Category.allCases, selection: $selectedCategory) { ... }
} detail: {
NavigationStack(path: $path) {
RecipeGrid(category: selectedCategory)
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
}
}
```
### 2.4 Column Visibility
```swift
@State private var columnVisibility: NavigationSplitViewVisibility = .all
NavigationSplitView(columnVisibility: $columnVisibility) {
Sidebar()
} content: {
Content()
} detail: {
Detail()
}
// Programmatically control visibility
columnVisibility = .detailOnly // Hide sidebar and content
columnVisibility = .all // Show all columns
columnVisibility = .automatic // System decides
```
### 2.5 Automatic Adaptation
NavigationSplitView automatically adapts:
- **iPad landscape** All columns visible (depending on configuration)
- **iPad portrait/Slide Over** Collapses to overlay or single column
- **iPhone** Single navigation stack
- **Apple Watch/TV** Single navigation stack
Selection changes automatically translate to push/pop on iPhone.
### 2.6 iOS 26+ Liquid Glass Sidebar (WWDC 2025, 323)
```swift
NavigationSplitView {
List { ... }
} detail: {
DetailView()
}
// Sidebar automatically gets Liquid Glass appearance on iPad/macOS
// Extend content behind glass sidebar
.backgroundExtensionEffect() // Mirrors and blurs content outside safe area
```
---
## Deep Linking and URL Routing
### 3.1 Deep Link Pattern
Use `.onOpenURL` to receive URLs, parse with `URLComponents`, then manipulate `NavigationPath`:
```swift
.onOpenURL { url in
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let host = components.host else { return }
path.removeLast(path.count) // Pop to root first
// Parse host/path to determine destination, then path.append(value)
}
```
For multi-step deep links (`myapp://category/desserts/recipe/apple-pie`), iterate URL path components and append each resolved value to build the full navigation stack.
For comprehensive deep linking examples, error diagnosis, and testing workflows, see `axiom-swiftui-nav-diag` (Pattern 3).
---
## State Restoration
### 4.1 Complete State Restoration (WWDC 2022, 18:12)
```swift
struct UseSceneStorage: View {
@StateObject private var navModel = NavigationModel()
@SceneStorage("navigation") private var data: Data?
@StateObject private var dataModel = DataModel()
var body: some View {
NavigationSplitView {
List(Category.allCases, selection: $navModel.selectedCategory) { category in
NavigationLink(category.localizedName, value: category)
}
.navigationTitle("Categories")
} detail: {
NavigationStack(path: $navModel.recipePath) {
RecipeGrid(category: navModel.selectedCategory)
}
}
.task {
// Restore on appear
if let data = data {
navModel.jsonData = data
}
// Save on changes
for await _ in navModel.objectWillChangeSequence {
data = navModel.jsonData
}
}
.environmentObject(dataModel)
}
}
```
### 4.2 Codable NavigationModel
```swift
class NavigationModel: ObservableObject, Codable {
@Published var selectedCategory: Category?
@Published var recipePath: [Recipe] = []
enum CodingKeys: String, CodingKey {
case selectedCategory
case recipePathIds // Store IDs, not full objects
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory)
try container.encode(recipePath.map(\.id), forKey: .recipePathIds)
}
init() {}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.selectedCategory = try container.decodeIfPresent(Category.self, forKey: .selectedCategory)
let recipePathIds = try container.decode([Recipe.ID].self, forKey: .recipePathIds)
self.recipePath = recipePathIds.compactMap { DataModel.shared[$0] } // Discard deleted items
}
}
```
Store IDs (not full model objects) and use `compactMap` to handle deleted items gracefully. Add `jsonData` computed property and `objectWillChangeSequence` for SceneStorage integration as shown in 4.1.
---
## Tab + Navigation Integration
### 5.1 Tab Syntax (iOS 18+) (WWDC 2024, 4:27)
```swift
TabView {
Tab("Watch Now", systemImage: "play") {
WatchNowView()
}
Tab("Library", systemImage: "books.vertical") {
LibraryView()
}
Tab(role: .search) {
NavigationStack {
SearchView()
.navigationTitle("Search")
}
.searchable(text: $searchText)
}
}
```
**Search tab requirement**: Contents of a search-role tab must be wrapped in `NavigationStack` with `.searchable()` applied to the stack. Without `NavigationStack`, the search field will not appear. For foundational `.searchable` patterns (suggestions, scopes, tokens, programmatic control), see `axiom-swiftui-search-ref`.
### 5.2 TabView with NavigationStack Per Tab
```swift
TabView {
Tab("Home", systemImage: "house") {
NavigationStack {
HomeView()
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item)
}
}
}
Tab("Settings", systemImage: "gear") {
NavigationStack {
SettingsView()
}
}
}
```
Each tab has its own NavigationStack to preserve navigation state when switching tabs.
### 5.3 Sidebar-Adaptable TabView (WWDC 2024, 6:41)
```swift
TabView {
Tab("Watch Now", systemImage: "play") {
WatchNowView()
}
Tab("Library", systemImage: "books.vertical") {
LibraryView()
}
TabSection("Collections") {
Tab("Cinematic Shots", systemImage: "list.and.film") {
CinematicShotsView()
}
Tab("Forest Life", systemImage: "list.and.film") {
ForestLifeView()
}
}
TabSection("Animations") {
// More tabs...
}
Tab(role: .search) {
SearchView()
}
}
.tabViewStyle(.sidebarAdaptable)
```
`TabSection` creates sidebar groups. `.sidebarAdaptable` enables sidebar on iPad, tab bar on iPhone. Search tab with `.search` role gets special placement.
### 5.4 Tab Customization (WWDC 2024, 10:45)
```swift
@AppStorage("MyTabViewCustomization")
private var customization: TabViewCustomization
TabView {
Tab("Watch Now", systemImage: "play", value: .watchNow) {
WatchNowView()
}
.customizationID("Tab.watchNow")
.customizationBehavior(.disabled, for: .sidebar, .tabBar) // Can't be hidden
Tab("Optional Tab", systemImage: "star", value: .optional) {
OptionalView()
}
.customizationID("Tab.optional")
.defaultVisibility(.hidden, for: .tabBar) // Hidden by default
}
.tabViewCustomization($customization)
```
### 5.5 Tab Context Menus
Use `.contextMenu(menuItems:)` on a `Tab` to add a context menu to its sidebar representation (e.g., right-click on Mac, long-press on iPad sidebar).
```swift
Tab("Currently Reading", systemImage: "book") {
CurrentBooksList()
}
.contextMenu {
Button {
pinnedTabs.insert(.reading)
} label: {
Label("Pin", systemImage: "pin")
}
Button {
showShareSheet = true
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
}
```
Return an empty closure to deactivate the context menu conditionally:
```swift
.contextMenu {
if canPin {
Button("Pin", systemImage: "pin") { pin() }
}
}
```
#### iPhone Tab Bar Long-Press
`.contextMenu` on `Tab` only applies to the sidebar representation (iPad/Mac). iPhone tab bar context menus require UIKit interop (adding `UILongPressGestureRecognizer` to `UITabBar` via Introspect or a `UITabBarController` subclass). See `axiom-swiftui-nav-diag` for workaround patterns.
**Caveat**: Relies on private `UITabBarButton` subviews — fragile across iOS versions, not a public API guarantee.
### 5.6 Programmatic Tab Visibility
Use `.hidden(_:)` to show/hide tabs based on app state while preserving their navigation state.
#### State-Driven Tab Visibility
```swift
Tab("Libraries", systemImage: "square.stack") { LibrariesView() }
.hidden(context == .browse) // Hide based on app state
```
Apply `.hidden(condition)` to each tab. Tabs hidden this way preserve their navigation state (unlike conditional `if` rendering which destroys and recreates them).
#### State Preservation
**Key difference**: `.hidden(_:)` preserves tab state, conditional rendering does not.
```swift
// ✅ State preserved when hidden
Tab("Settings", systemImage: "gear") {
SettingsView() // Navigation stack preserved
}
.hidden(!showSettings)
// ❌ State lost when condition changes
if showSettings {
Tab("Settings", systemImage: "gear") {
SettingsView() // Navigation stack recreated
}
}
```
#### Common Patterns
```swift
Tab("Beta Features", systemImage: "flask") { BetaView() }
.hidden(!UserDefaults.standard.bool(forKey: "enableBetaFeatures"))
```
Same pattern applies to authentication state, purchase status, and debug builds — bind `.hidden()` to any boolean condition.
#### Animated Transitions
Wrap state changes in `withAnimation` for smooth tab bar layout transitions:
```swift
Button("Switch to Browse") {
withAnimation {
context = .browse
selection = .tracks // Switch to first visible tab
}
}
// Tab bar animates as tabs appear/disappear
// Uses system motion curves automatically
```
### 5.7 iOS 26+ Tab Features (WWDC 2025, 256)
```swift
// Tab bar minimization on scroll
TabView { ... }
.tabBarMinimizeBehavior(.onScrollDown)
// Bottom accessory view (always visible)
TabView { ... }
.tabViewBottomAccessory {
PlaybackControls()
}
// Dynamic visibility (recommended for mini-players)
// ⚠️ Requires iOS 26.1+ (not 26.0)
TabView { ... }
.tabViewBottomAccessory(isEnabled: showMiniPlayer) {
MiniPlayerView()
.transition(.opacity)
}
// isEnabled: true = shows accessory
// isEnabled: false = hides AND removes reserved space
// Search tab with dedicated search field
Tab(role: .search) {
NavigationStack {
SearchView()
.navigationTitle("Search")
}
.searchable(text: $searchText)
}
// Morphs into search field when selected
// ⚠️ NavigationStack wrapper required for search field to appear
// Fallback: If no tab has .search role, the tab view applies search
// to ALL tabs, resetting search state when the selected tab changes
```
#### Dynamic Bottom Accessory
The accessory can switch on `activeTab` for per-tab content, though Apple's usage (Music mini-player) keeps it global. Read `@Environment(\.tabViewBottomAccessoryPlacement)` to adapt layout: `.bar` when above tab bar (full controls), other values when inline with collapsed tab bar (compact).
Reserve `tabViewBottomAccessory` for cross-tab content (playback, status). For tab-specific actions, prefer floating glass buttons within the tab's content view.
### 5.8 Tab API Quick Reference
| Modifier | Target | iOS | Purpose |
|----------|--------|-----|---------|
| `Tab(_:systemImage:value:content:)` | — | 18+ | New tab syntax with selection value |
| `Tab(role: .search)` | — | 18+ | Semantic search tab with morph behavior |
| `TabSection(_:content:)` | — | 18+ | Group tabs in sidebar view |
| `.contextMenu(menuItems:)` | Tab | 18+ | Add context menu to tab's sidebar representation |
| `.customizationID(_:)` | Tab | 18+ | Enable user customization |
| `.customizationBehavior(_:for:)` | Tab | 18+ | Control hide/reorder permissions |
| `.defaultVisibility(_:for:)` | Tab | 18+ | Set initial visibility state |
| `.hidden(_:)` | Tab | 18+ | Programmatic visibility with state preservation |
| `.tabViewStyle(.sidebarAdaptable)` | TabView | 18+ | Sidebar on iPad, tabs on iPhone |
| `.tabViewCustomization($binding)` | TabView | 18+ | Persist user tab arrangement |
| `.tabBarMinimizeBehavior(_:)` | TabView | 26+ | Auto-hide on scroll |
| `.tabViewBottomAccessory(isEnabled:content:)` | TabView | 26.1+ | Dynamic content below tab bar |
---
## iOS 26+ Navigation Features
### 6.1 Liquid Glass Navigation (WWDC 2025, 323)
Automatic adoption when building with Xcode 26:
- Navigation bars become Liquid Glass
- Sidebars float above content with glass effect
- Tab bars float with new compact appearance
- Toolbars get automatic grouping
### 6.2 Background Extension Effect
```swift
NavigationSplitView {
Sidebar()
} detail: {
HeroImage()
.backgroundExtensionEffect() // Content extends behind sidebar
}
```
### 6.3 Bottom-Aligned Search (WWDC 2025, 256)
**Foundational search APIs** For `.searchable`, `isSearching`, suggestions, scopes, tokens, and programmatic control, see `axiom-swiftui-search-ref`. This section covers iOS 26 bottom-aligned refinement only.
```swift
NavigationSplitView {
Sidebar()
} detail: {
DetailView()
}
.searchable(text: $query, prompt: "What are you looking for?")
// Automatically bottom-aligned on iPhone, top-trailing on iPad
```
### 6.4 Scroll Edge Effect
```swift
// Automatic blur effect when content scrolls under toolbar
// Remove any custom darkening backgrounds - they interfere
// For dense UIs, adjust sharpness
ScrollView { ... }
.scrollEdgeEffectStyle(.soft) // .sharp, .soft
```
### 6.5 Sheet Presentations with Zoom Transition
In iOS 26, sheets can morph directly out of the buttons that present them. Make the presenting toolbar item a source for a navigation zoom transition, and mark the sheet content as the destination:
```swift
@Namespace private var namespace
// Sheet morphs out of presenting button
.toolbar {
ToolbarItem {
Button("Settings") { showSettings = true }
.matchedTransitionSource(id: "settings", in: namespace)
}
}
.sheet(isPresented: $showSettings) {
SettingsView()
.navigationTransition(.zoom(sourceID: "settings", in: namespace))
}
```
Other presentations also flow smoothly out of Liquid Glass controls — menus, alerts, and popovers. Dialogs automatically morph out of the buttons that present them without additional code.
**Audit tip**: If you've used `presentationBackground` to apply custom backgrounds to sheets, consider removing it and let the new Liquid Glass sheet material shine. Partial height sheets are now inset with glass background by default.
### 6.6 Toolbar Morphing Transitions
iOS 26 automatically morphs toolbars during NavigationStack push/pop when each destination view declares its own `.toolbar {}`. Items with matching `toolbar(id:)` and `ToolbarItem(id:)` IDs stay stable during the transition (no bounce), while unmatched items animate in/out.
**Key rule**: Attach `.toolbar {}` to individual views inside NavigationStack, not to NavigationStack itself. Otherwise there is nothing to morph between.
See `axiom-swiftui-26-ref` skill for complete toolbar morphing API including DefaultToolbarItem, `toolbar(id:)` stable items, ToolbarSpacer patterns, and troubleshooting.
---
## Router/Coordinator Patterns
### 7.1 When to Use Coordinators
**Use coordinators when:**
- Navigation logic is complex with conditional flows
- Testing navigation in isolation
- Sharing navigation logic across multiple screens
- UIKit interop with heavy navigation requirements
**Use built-in navigation when:**
- Simple linear or hierarchical navigation
- State restoration is primary concern
- Fewer than 5-10 navigation destinations
- No need for navigation unit testing
### 7.2 Simple Router Pattern
```swift
// Route enum defines all possible destinations
enum AppRoute: Hashable {
case home
case category(Category)
case recipe(Recipe)
case settings
}
// Router class manages navigation
@Observable
class Router {
var path = NavigationPath()
func navigate(to route: AppRoute) {
path.append(route)
}
func popToRoot() {
path.removeLast(path.count)
}
func pop() {
if !path.isEmpty {
path.removeLast()
}
}
}
// Usage in views
struct ContentView: View {
@State private var router = Router()
var body: some View {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .home:
HomeView()
case .category(let category):
CategoryView(category: category)
case .recipe(let recipe):
RecipeDetail(recipe: recipe)
case .settings:
SettingsView()
}
}
}
.environment(router)
}
}
// In child views
struct RecipeCard: View {
let recipe: Recipe
@Environment(Router.self) private var router
var body: some View {
Button(recipe.name) {
router.navigate(to: .recipe(recipe))
}
}
}
```
### 7.3 Coordinator Pattern with Protocol
For larger apps, extract a `Coordinator` protocol with `associatedtype Route: Hashable` and `var path: NavigationPath`. Each feature area gets its own coordinator conformance with domain-specific routes and convenience methods (e.g., `showRecipeOfTheDay()` that resets path and navigates).
### 7.4 Testing Navigation
```swift
// Router is easily testable
func testNavigateToRecipe() {
let router = Router()
let recipe = Recipe(name: "Apple Pie")
router.navigate(to: .recipe(recipe))
XCTAssertEqual(router.path.count, 1)
}
func testPopToRoot() {
let router = Router()
router.navigate(to: .category(.desserts))
router.navigate(to: .recipe(Recipe(name: "Apple Pie")))
router.popToRoot()
XCTAssertTrue(router.path.isEmpty)
}
```
---
## Testing Checklist
- [ ] Deep links navigate correctly from cold start AND while running
- [ ] Pop to root clears entire stack
- [ ] State restores on app relaunch (SceneStorage key unique per scene)
- [ ] Deleted items handled gracefully in restoration (compactMap)
- [ ] NavigationSplitView collapses correctly on iPhone (selection pushes)
- [ ] iOS 26+: Liquid Glass appearance, bottom-aligned search, tab bar minimization
---
## API Quick Reference
### NavigationStack
```swift
NavigationStack { content }
NavigationStack(path: $path) { content }
```
### NavigationSplitView
```swift
NavigationSplitView { sidebar } detail: { detail }
NavigationSplitView { sidebar } content: { content } detail: { detail }
NavigationSplitView(columnVisibility: $visibility) { ... }
```
### NavigationLink
```swift
NavigationLink(title, value: value)
NavigationLink(value: value) { label }
```
### NavigationPath
```swift
path.append(value)
path.removeLast()
path.removeLast(path.count)
path.count
path.codable // For encoding
NavigationPath(codableRepresentation) // For decoding
```
### Modifiers
```swift
.navigationTitle("Title")
.navigationDestination(for: Type.self) { value in View }
.searchable(text: $query)
.tabViewStyle(.sidebarAdaptable)
.tabBarMinimizeBehavior(.onScrollDown)
.backgroundExtensionEffect()
```
---
## Resources
**WWDC**: 2022-10054, 2024-10147, 2025-256, 2025-323 (Build a SwiftUI app with the new design)
**Docs**: /swiftui/tabrole/search, /swiftui/view/tabbarminimizebehavior(_:), /swiftui/view/tabviewbottomaccessory(isenabled:content:)
**Skills**: axiom-swiftui-nav, axiom-swiftui-nav-diag, axiom-swiftui-26-ref, axiom-liquid-glass, axiom-swiftui-search-ref
---
**Last Updated** Based on WWDC 2022-10054, WWDC 2024-10147, WWDC 2025-256, WWDC 2025-323 (Build a SwiftUI app with the new design)
**Platforms** iOS 16+, iPadOS 16+, macOS 13+, watchOS 9+, tvOS 16+
Name Size