--- name: axiom-swiftui-nav description: Use when implementing navigation patterns, choosing between NavigationStack and NavigationSplitView, handling deep links, adopting coordinator patterns, or requesting code review of navigation implementation - prevents navigation state corruption, deep link failures, and state restoration bugs for iOS 18+ license: MIT compatibility: iOS 18+ (Tab/Sidebar), iOS 26+ (Liquid Glass) metadata: version: "1.0.0" last-updated: "2025-12-05" --- # SwiftUI Navigation ## When to Use This Skill Use when: - Choosing navigation architecture (NavigationStack vs NavigationSplitView vs TabView) - Implementing programmatic navigation with NavigationPath - Setting up deep linking and URL routing - Implementing state restoration for navigation - Adopting Tab/Sidebar patterns (iOS 18+) - Implementing coordinator/router patterns - Requesting code review of navigation implementation before shipping #### Related Skills - Use `axiom-swiftui-nav-diag` for systematic troubleshooting of navigation failures - Use `axiom-swiftui-nav-ref` for comprehensive API reference (including Tab customization, iOS 26+ features) with all WWDC examples ## Example Prompts These are real questions developers ask that this skill is designed to answer: #### 1. "Should I use NavigationStack or NavigationSplitView for my app?" -> The skill provides a decision tree based on device targets, content hierarchy depth, and multiplatform requirements #### 2. "How do I navigate programmatically in SwiftUI?" -> The skill shows NavigationPath manipulation patterns for push, pop, pop-to-root, and deep linking #### 3. "My deep links aren't working. The app opens but shows the wrong screen." -> The skill covers URL parsing patterns, path construction order, and timing issues with onOpenURL #### 4. "Navigation state is lost when my app goes to background." -> The skill demonstrates Codable NavigationPath, SceneStorage persistence, and crash-resistant restoration #### 5. "How do I implement a coordinator pattern in SwiftUI?" -> The skill provides Router pattern examples alongside guidance on when coordinators add value vs complexity --- ## Red Flags — Anti-Patterns to Prevent If you're doing ANY of these, STOP and use the patterns in this skill: ### ❌ CRITICAL — Never Do These #### 1. Using deprecated NavigationView on iOS 16+ ```swift // ❌ WRONG — Deprecated, different behavior on iOS 16+ NavigationView { List { ... } } .navigationViewStyle(.stack) ``` **Why this fails** NavigationView is deprecated since iOS 16. It lacks NavigationPath support, making programmatic navigation and deep linking unreliable. Different behavior across iOS versions causes bugs. #### 2. Using view-based NavigationLink for programmatic navigation ```swift // ❌ WRONG — Cannot programmatically control NavigationLink("Recipe") { RecipeDetail(recipe: recipe) // View destination, no value } ``` **Why this fails** View-based links cannot be controlled programmatically. No way to deep link or pop to this destination. Deprecated since iOS 16. #### 3. Putting navigationDestination inside lazy containers ```swift // ❌ WRONG — May not be loaded when needed LazyVGrid(columns: columns) { ForEach(items) { item in NavigationLink(value: item) { ... } .navigationDestination(for: Item.self) { item in // Don't do this ItemDetail(item: item) } } } ``` **Why this fails** Lazy containers don't load all views immediately. navigationDestination may not be visible to NavigationStack, causing navigation to silently fail. #### 4. Storing full model objects in NavigationPath for restoration ```swift // ❌ WRONG — Duplicates data, stale on restore class NavigationModel: Codable { var path: [Recipe] = [] // Full Recipe objects } ``` **Why this fails** Duplicates data already in your model. On restore, Recipe data may be stale (edited/deleted elsewhere). Use IDs and resolve to current data. #### 5. Modifying NavigationPath outside MainActor ```swift // ❌ WRONG — UI update off main thread Task.detached { await viewModel.path.append(recipe) // Background thread } ``` **Why this fails** NavigationPath binds to UI. Modifications must happen on MainActor or navigation state becomes corrupted. Can cause crashes or silent failures. #### 6. Missing @MainActor isolation for navigation state ```swift // ❌ WRONG — Not MainActor isolated class Router: ObservableObject { @Published var path = NavigationPath() // No @MainActor } ``` **Why this fails** In Swift 6 strict concurrency, @Published properties accessed from SwiftUI views require MainActor isolation. Causes data race warnings and potential crashes. #### 7. Not handling navigation state in multi-tab apps ```swift // ❌ WRONG — Shared NavigationPath across tabs TabView { Tab("Home") { HomeView() } Tab("Settings") { SettingsView() } } // All tabs share same NavigationStack — wrong! ``` **Why this fails** Each tab should have its own NavigationStack to preserve navigation state when switching tabs. Shared state causes confusing UX. #### 8. Ignoring NavigationPath decoding errors ```swift // ❌ WRONG — Crashes on invalid data let path = NavigationPath(try! decoder.decode(NavigationPath.CodableRepresentation.self, from: data)) ``` **Why this fails** User may have deleted items that were in the path. Schema may have changed. Force unwrap causes crash on restore. --- ## Mandatory First Steps **ALWAYS complete these steps** before implementing navigation: ```swift // Step 1: Identify your navigation structure // Ask: Single stack? Multi-column? Tab-based with per-tab navigation? // Record answer before writing any code // Step 2: Choose container based on structure // Single stack (iPhone-primary): NavigationStack // Multi-column (iPad/Mac-primary): NavigationSplitView // Tab-based: TabView with NavigationStack per tab // Step 3: Define your value types for navigation // All values pushed on NavigationStack must be Hashable // For deep linking/restoration, also Codable struct Recipe: Hashable, Codable, Identifiable { ... } // Step 4: Plan deep link URLs (if needed) // myapp://recipe/{id} // myapp://category/{name}/recipe/{id} // Step 5: Plan state restoration (if needed) // Will you use SceneStorage? What data must be Codable? ``` --- ## Quick Decision Tree ``` Need navigation? ├─ Multi-column interface (iPad/Mac primary)? │ └─ NavigationSplitView │ ├─ Need drill-down in detail column? │ │ └─ NavigationStack inside detail (Pattern 3) │ └─ Selection-only detail? │ └─ Just selection binding (Pattern 2) ├─ Tab-based app? │ └─ TabView │ ├─ Each tab needs drill-down? │ │ └─ NavigationStack per tab (Pattern 4) │ └─ iPad sidebar experience? │ └─ .tabViewStyle(.sidebarAdaptable) (Pattern 5) └─ Single-column stack? └─ NavigationStack ├─ Need deep linking? │ └─ Use NavigationPath (Pattern 1b) └─ Simple push/pop? └─ Typed array path (Pattern 1a) Need state restoration? └─ SceneStorage + Codable NavigationPath (Pattern 6) Need coordinator abstraction? ├─ Complex conditional flows? ├─ Navigation logic testing needed? ├─ Sharing navigation across many screens? └─ YES to any → Router pattern (Pattern 7) NO to all → Use NavigationPath directly ``` --- ## Pattern 1a: Basic NavigationStack **When**: Simple push/pop navigation, all destinations same type **Time cost**: 5-10 min ```swift struct RecipeList: View { @State private var path: [Recipe] = [] var body: some View { NavigationStack(path: $path) { List(recipes) { recipe in NavigationLink(recipe.name, value: recipe) } .navigationTitle("Recipes") .navigationDestination(for: Recipe.self) { recipe in RecipeDetail(recipe: recipe) } } } // Programmatic navigation func showRecipe(_ recipe: Recipe) { path.append(recipe) } func popToRoot() { path.removeAll() } } ``` **Key points:** - Typed array `[Recipe]` when all values are same type - Value-based `NavigationLink(title, value:)` - `navigationDestination(for:)` outside lazy containers --- ## Pattern 1b: NavigationStack with Deep Linking **When**: Multiple destination types, URL-based deep linking **Time cost**: 15-20 min ```swift struct ContentView: View { @State private var path = NavigationPath() var body: some View { NavigationStack(path: $path) { HomeView() .navigationDestination(for: Category.self) { category in CategoryView(category: category) } .navigationDestination(for: Recipe.self) { recipe in RecipeDetail(recipe: recipe) } } .onOpenURL { url in handleDeepLink(url) } } func handleDeepLink(_ url: URL) { // URL: myapp://category/desserts/recipe/apple-pie path.removeLast(path.count) // Pop to root first guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return } let segments = components.path.split(separator: "/").map(String.init) var index = 0 while index < segments.count - 1 { switch segments[index] { case "category": if let category = Category(rawValue: segments[index + 1]) { path.append(category) } index += 2 case "recipe": if let recipe = dataModel.recipe(named: segments[index + 1]) { path.append(recipe) } index += 2 default: index += 1 } } } } ``` **Key points:** - `NavigationPath` for heterogeneous types - Pop to root before building deep link path - Build path in correct order (parent → child) --- ## Pattern 2: NavigationSplitView Selection-Based **When**: Multi-column layout where detail shows selected item **Time cost**: 10-15 min ```swift struct MultiColumnView: View { @State private var selectedCategory: Category? @State private var selectedRecipe: Recipe? var body: some View { NavigationSplitView { List(Category.allCases, selection: $selectedCategory) { category in NavigationLink(category.name, value: category) } .navigationTitle("Categories") } content: { if let category = selectedCategory { List(recipes(in: category), selection: $selectedRecipe) { recipe in NavigationLink(recipe.name, value: recipe) } .navigationTitle(category.name) } else { Text("Select a category") } } detail: { if let recipe = selectedRecipe { RecipeDetail(recipe: recipe) } else { Text("Select a recipe") } } } } ``` **Key points:** - `selection: $binding` on List connects to column selection - Value-presenting links update selection automatically - Adapts to single stack on iPhone --- ## Pattern 3: NavigationSplitView with Stack in Detail **When**: Multi-column with drill-down capability in detail **Time cost**: 20-25 min ```swift struct GridWithDrillDown: View { @State private var selectedCategory: Category? @State private var path: [Recipe] = [] var body: some View { NavigationSplitView { List(Category.allCases, selection: $selectedCategory) { category in NavigationLink(category.name, value: category) } .navigationTitle("Categories") } detail: { NavigationStack(path: $path) { if let category = selectedCategory { RecipeGrid(category: category) .navigationDestination(for: Recipe.self) { recipe in RecipeDetail(recipe: recipe) } } else { Text("Select a category") } } } } } ``` **Key points:** - NavigationStack inside detail column - Grid → Detail drill-down while preserving sidebar - Separate path for drill-down, selection for sidebar --- ## Pattern 4: TabView with Per-Tab NavigationStack **When**: Tab-based app where each tab has its own navigation **Time cost**: 15-20 min ```swift struct TabBasedApp: View { var body: some View { TabView { Tab("Home", systemImage: "house") { NavigationStack { HomeView() .navigationDestination(for: Item.self) { item in ItemDetail(item: item) } } } Tab("Search", systemImage: "magnifyingglass") { NavigationStack { SearchView() } } Tab("Settings", systemImage: "gear") { NavigationStack { SettingsView() } } } } } ``` **Key points:** - Each Tab has its own NavigationStack - Navigation state preserved when switching tabs - iOS 18+ Tab syntax with systemImage --- ## Pattern 5: Sidebar-Adaptable TabView (iOS 18+) **When**: Tab bar on iPhone, sidebar on iPad **Time cost**: 20-25 min ```swift struct AdaptableApp: View { var body: some View { TabView { Tab("Watch Now", systemImage: "play") { WatchNowView() } Tab("Library", systemImage: "books.vertical") { LibraryView() } TabSection("Collections") { Tab("Favorites", systemImage: "star") { FavoritesView() } Tab("Recently Added", systemImage: "clock") { RecentView() } } Tab(role: .search) { SearchView() } } .tabViewStyle(.sidebarAdaptable) } } ``` **Key points:** - `.tabViewStyle(.sidebarAdaptable)` enables sidebar on iPad - `TabSection` creates collapsible groups in sidebar - `Tab(role: .search)` gets special placement --- ## Pattern 6: State Restoration **When**: Preserve navigation state across app launches **Time cost**: 25-30 min ```swift @MainActor class NavigationModel: ObservableObject, Codable { @Published var selectedCategory: Category? @Published var recipePath: [Recipe.ID] = [] // Store IDs, not objects enum CodingKeys: String, CodingKey { case selectedCategory, recipePath } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory) try container.encode(recipePath, forKey: .recipePath) } required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) selectedCategory = try container.decodeIfPresent(Category.self, forKey: .selectedCategory) recipePath = try container.decode([Recipe.ID].self, forKey: .recipePath) } init() {} var jsonData: Data? { get { try? JSONEncoder().encode(self) } set { guard let data = newValue, let model = try? JSONDecoder().decode(NavigationModel.self, from: data) else { return } selectedCategory = model.selectedCategory recipePath = model.recipePath } } } struct ContentView: View { @StateObject private var navModel = NavigationModel() @SceneStorage("navigation") private var data: Data? var body: some View { NavigationStack(path: $navModel.recipePath) { // Content } .task { if let data { navModel.jsonData = data } for await _ in navModel.objectWillChange.values { data = navModel.jsonData } } } } ``` **Key points:** - Store IDs, resolve to current objects - `@MainActor` for Swift 6 concurrency safety - SceneStorage for automatic scene-scoped persistence - Use `compactMap` when resolving IDs to handle deleted items --- ## Pattern 7: Router/Coordinator **When**: Complex navigation logic, need testability **Time cost**: 30-45 min ```swift enum AppRoute: Hashable { case home case category(Category) case recipe(Recipe) case settings } @Observable @MainActor class Router { var path = NavigationPath() func navigate(to route: AppRoute) { path.append(route) } func pop() { guard !path.isEmpty else { return } path.removeLast() } func popToRoot() { path.removeLast(path.count) } func showRecipeOfTheDay() { popToRoot() if let recipe = DataModel.shared.recipeOfTheDay { path.append(AppRoute.recipe(recipe)) } } } 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 cat): CategoryView(category: cat) case .recipe(let recipe): RecipeDetail(recipe: recipe) case .settings: SettingsView() } } } .environment(router) } } ``` **When coordinators add value:** - Complex conditional navigation flows - Navigation logic needs unit testing - Multiple views trigger same navigation - UIKit interop with custom transitions **When coordinators add complexity without value:** - Simple linear navigation - < 5 navigation destinations - No need for navigation testing - NavigationPath already handles your deep linking --- ## Anti-Patterns (DO NOT DO THIS) ### ❌ Nesting NavigationStack inside NavigationStack ```swift // ❌ WRONG — Nested stacks NavigationStack { SomeView() .sheet(isPresented: $showSheet) { NavigationStack { // Creates separate stack — confusing SheetContent() } } } ``` **Issue** Two navigation stacks create confusing UX. Back button behavior unclear. **Fix** Use single NavigationStack, present sheets without nested navigation when possible. ### ❌ Using NavigationLink inside Button ```swift // ❌ WRONG — Double navigation triggers Button("Go") { // Some action } label: { NavigationLink(value: item) { // Fires on button AND link Text("Item") } } ``` **Issue** Both Button and NavigationLink respond to taps. **Fix** Use only NavigationLink, put action in `.simultaneousGesture` if needed. ### ❌ Creating NavigationPath in view body ```swift // ❌ WRONG — Recreated every render var body: some View { let path = NavigationPath() // Reset on every render! NavigationStack(path: .constant(path)) { ... } } ``` **Issue** Path recreated each render, navigation state lost. **Fix** Use `@State` or `@StateObject` for navigation state. --- ## Pressure Scenario: "Make Navigation Like Instagram" ### The Problem Product/design asks for complex navigation like Instagram: - "Tab bar with per-tab navigation stacks" - "Smooth coordinator pattern for all flows" - "Deep linking to any screen" - "Profile accessible from anywhere" ### Red Flags — Recognize Over-Engineering Pressure If you hear ANY of these, **STOP and evaluate**: - 🚩 **"Let's build a full coordinator layer before any views"** → Usually YAGNI - 🚩 **"We need a navigation architecture that handles anything"** → Scope creep - 🚩 **"Instagram/TikTok does it this way"** → They have 100+ engineers ### Time Cost Comparison #### Option A: Over-Engineered Coordinator - Time to build coordinator layer: 3-5 days - Time to maintain and debug: Ongoing - Time when requirements change: Significant refactor #### Option B: Built-in Navigation + Simple Router - Time to implement Pattern 4 (TabView + NavigationStack): 2-3 hours - Time to add Router if needed: 1-2 hours - Time when requirements change: Incremental additions ### How to Push Back Professionally #### Step 1: Quantify Current Needs ``` "Let's list our actual navigation flows: 1. Home → Item Detail 2. Search → Results → Item Detail 3. Profile → Settings That's 6 destinations. NavigationPath handles this natively." ``` #### Step 2: Show the Built-in Solution ``` "Here's our navigation with NavigationStack + NavigationPath: [Show Pattern 1b code] This gives us: - Programmatic navigation ✓ - Deep linking ✓ - State restoration ✓ - Type safety ✓ Without a coordinator layer." ``` #### Step 3: Offer Incremental Path ``` "If we find NavigationPath insufficient, we can add a Router (Pattern 7) later. It's 30-45 minutes of work. But let's start with the simpler solution and add complexity only when we hit a real limitation." ``` ### Real-World Example: 48-Hour Feature Push **Scenario:** - PM: "We need deep linking for the campaign launch in 2 days" - Lead: "Let's build a proper coordinator first" - Time available: 16 working hours **Wrong approach:** - 8 hours: Build coordinator infrastructure - 4 hours: Debug coordinator edge cases - 4 hours: Rush deep linking on broken foundation - Result: Buggy, deadline missed **Correct approach:** - 2 hours: Implement Pattern 1b (NavigationStack with deep linking) - 1 hour: Test all deep link URLs - 1 hour: Add SceneStorage restoration (Pattern 6) - Result: Working deep links in 4 hours, 12 hours for polish/testing --- ## Pressure Scenario: "NavigationView Backward Compatibility" ### The Problem Team lead says: "Let's use NavigationView so we support iOS 15" ### Red Flags - 🚩 NavigationView deprecated since iOS 16 (2022) - 🚩 Different behavior across iOS versions causes bugs - 🚩 No NavigationPath support — can't deep link properly ### Data to Share ``` iOS 16+ adoption: 95%+ of active devices (as of 2024) iOS 15: < 5% and declining NavigationView limitations: - No programmatic path manipulation - No type-safe navigation - No built-in state restoration - Behavior varies by iOS version ``` ### Push-Back Script ``` "NavigationView was deprecated in iOS 16 (2022). Here's the impact: 1. We lose NavigationPath — can't implement deep linking reliably 2. Behavior differs between iOS 15 and 16 — more bugs to maintain 3. iOS 15 is < 5% of users — we're adding complexity for small audience Recommendation: Set deployment target to iOS 16, use NavigationStack. If iOS 15 support is required, use NavigationStack with @available checks and fallback UI for older devices." ``` --- ## Code Review Checklist ### Navigation Architecture - [ ] Correct container for use case (Stack vs SplitView vs TabView) - [ ] Value-based NavigationLink (not view-based) - [ ] navigationDestination outside lazy containers - [ ] Each tab has own NavigationStack (if tab-based) ### State Management - [ ] NavigationPath in @State or @StateObject (not recreated in body) - [ ] @MainActor isolation for navigation state (Swift 6) - [ ] IDs stored for restoration (not full objects) - [ ] Error handling for decode failures ### Deep Linking - [ ] onOpenURL handler present - [ ] Pop to root before building path - [ ] Path built in correct order (parent → child) - [ ] Missing data handled gracefully ### iOS 26+ Features - [ ] No custom backgrounds interfering with Liquid Glass - [ ] Bottom-aligned search working on iPhone - [ ] Tab bar minimization if appropriate --- ## Troubleshooting Quick Reference | Symptom | Likely Cause | Pattern | |---------|--------------|---------| | Navigation doesn't respond to taps | NavigationLink outside NavigationStack | Check hierarchy | | Double navigation on tap | Button wrapping NavigationLink | Remove Button wrapper | | State lost on tab switch | Shared NavigationStack across tabs | Pattern 4 | | State lost on background | No SceneStorage | Pattern 6 | | Deep link shows wrong screen | Path built in wrong order | Pattern 1b | | Crash on restore | Force unwrap decode | Handle errors gracefully | --- ## Resources **WWDC**: 2022-10054, 2024-10147, 2025-256, 2025-323 **Skills**: axiom-swiftui-nav-diag, axiom-swiftui-nav-ref --- **Last Updated** Based on WWDC 2022-2025 navigation sessions **Platforms** iOS 18+, iPadOS 18+, macOS 15+, watchOS 11+, tvOS 18+