Skip to content

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
  • Use swiftui-nav-diag for systematic troubleshooting of navigation failures
  • Use swiftui-nav-ref for comprehensive API reference 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

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

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.

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

  • [ ] 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

SymptomLikely CausePattern
Navigation doesn't respond to tapsNavigationLink outside NavigationStackCheck hierarchy
Double navigation on tapButton wrapping NavigationLinkRemove Button wrapper
State lost on tab switchShared NavigationStack across tabsPattern 4
State lost on backgroundNo SceneStoragePattern 6
Deep link shows wrong screenPath built in wrong orderPattern 1b
Crash on restoreForce unwrap decodeHandle errors gracefully

WWDC References


Last Updated Based on WWDC 2022-2025 navigation sessions Platforms iOS 18+, iPadOS 18+, macOS 15+, watchOS 11+, tvOS 18+

Released under the MIT License