home / skills / kaakati / rails-enterprise-dev / navigation-patterns

This skill guides you in selecting SwiftUI navigation architectures, managing navigation state, and implementing robust deep links for scalable iOS apps.

npx playbooks add skill kaakati/rails-enterprise-dev --skill navigation-patterns

Review the files below or copy the command above to add this skill to your agents.

Files (2)
SKILL.md
10.6 KB
---
name: navigation-patterns
description: "Expert navigation decisions for iOS/tvOS: when NavigationStack vs Coordinator patterns, NavigationPath state management trade-offs, deep link architecture choices, and tab+navigation coordination strategies. Use when designing app navigation, implementing deep links, or debugging navigation state issues. Trigger keywords: NavigationStack, NavigationPath, deep link, routing, tab bar, navigation, programmatic navigation, universal link, URL scheme, navigation state"
version: "3.0.0"
---

# Navigation Patterns — Expert Decisions

Expert decision frameworks for SwiftUI navigation choices. Claude knows NavigationStack syntax — this skill provides judgment calls for architecture decisions and state management trade-offs.

---

## Decision Trees

### Navigation Architecture Selection

```
How complex is your navigation?
├─ Simple (linear flows, 1-3 screens)
│  └─ NavigationStack with inline NavigationLink
│     No Router needed
│
├─ Medium (multiple flows, deep linking required)
│  └─ NavigationStack + Router (ObservableObject)
│     Centralized navigation state
│
└─ Complex (tabs with independent stacks, cross-tab navigation)
   └─ Tab Coordinator + per-tab Routers
      Each tab maintains own NavigationPath
```

### NavigationPath vs Typed Array

```
Do you need heterogeneous routes?
├─ YES (different types in same stack)
│  └─ NavigationPath (type-erased)
│     path.append(User(...))
│     path.append(Product(...))
│
└─ NO (single route enum)
   └─ @State var path: [Route] = []
      Type-safe, debuggable, serializable
```

**Rule**: Prefer typed arrays unless you genuinely need mixed types. NavigationPath's type erasure makes debugging harder.

### Deep Link Handling Strategy

```
When does deep link arrive?
├─ App already running (warm start)
│  └─ Direct navigation via Router
│
└─ App launches from deep link (cold start)
   └─ Is view hierarchy ready?
      ├─ YES → Navigate immediately
      └─ NO → Queue pending deep link
         Handle in root view's .onAppear
```

### Modal vs Push Selection

```
Is the destination a self-contained flow?
├─ YES (can complete/cancel independently)
│  └─ Modal (.sheet or .fullScreenCover)
│     Examples: Settings, Compose, Login
│
└─ NO (part of current navigation hierarchy)
   └─ Push (NavigationLink or path.append)
      Examples: Detail views, drill-down
```

---

## NEVER Do

### NavigationPath State

**NEVER** store NavigationPath in ViewModel without careful consideration:
```swift
// ❌ ViewModel owns navigation — couples business logic to navigation
@MainActor
final class HomeViewModel: ObservableObject {
    @Published var path = NavigationPath()  // Wrong layer!
}

// ✅ Router/Coordinator owns navigation, ViewModel owns data
@MainActor
final class Router: ObservableObject {
    @Published var path = NavigationPath()
}

@MainActor
final class HomeViewModel: ObservableObject {
    @Published var items: [Item] = []  // Data only
}
```

**NEVER** use NavigationPath across tabs:
```swift
// ❌ Shared path across tabs — navigation becomes unpredictable
struct MainTabView: View {
    @StateObject var router = Router()  // Single router!

    var body: some View {
        TabView {
            // Both tabs share same path — chaos
        }
    }
}

// ✅ Each tab has independent navigation stack
struct MainTabView: View {
    @StateObject var homeRouter = Router()
    @StateObject var searchRouter = Router()

    var body: some View {
        TabView {
            NavigationStack(path: $homeRouter.path) { ... }
            NavigationStack(path: $searchRouter.path) { ... }
        }
    }
}
```

**NEVER** forget to handle deep links arriving before view hierarchy:
```swift
// ❌ Race condition — navigation may fail silently
@main
struct MyApp: App {
    @StateObject var router = Router()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .onOpenURL { url in
                    router.handle(url)  // View may not exist yet!
                }
        }
    }
}

// ✅ Queue deep link for deferred handling
@main
struct MyApp: App {
    @StateObject var router = Router()
    @State private var pendingDeepLink: URL?

    var body: some Scene {
        WindowGroup {
            ContentView()
                .onAppear {
                    if let url = pendingDeepLink {
                        router.handle(url)
                        pendingDeepLink = nil
                    }
                }
                .onOpenURL { url in
                    pendingDeepLink = url
                }
        }
    }
}
```

### Route Design

**NEVER** use stringly-typed routes:
```swift
// ❌ No compile-time safety, typos cause runtime failures
func navigate(to screen: String) {
    switch screen {
    case "profile": ...
    case "setings": ...  // Typo — silent failure
    }
}

// ✅ Enum routes with associated values
enum Route: Hashable {
    case profile(userId: String)
    case settings
}
```

**NEVER** put navigation logic in Views:
```swift
// ❌ View knows too much about app structure
struct ItemRow: View {
    var body: some View {
        NavigationLink {
            ItemDetailView(item: item)  // View creates destination
        } label: {
            Text(item.name)
        }
    }
}

// ✅ Delegate navigation to Router
struct ItemRow: View {
    @EnvironmentObject var router: Router

    var body: some View {
        Button(item.name) {
            router.navigate(to: .itemDetail(item.id))
        }
    }
}
```

### Navigation State Persistence

**NEVER** lose navigation state on app termination without consideration:
```swift
// ❌ User loses their place when app is killed
@StateObject var router = Router()  // State lost on terminate

// ✅ Persist for important flows (optional based on UX needs)
@SceneStorage("navigationPath") private var pathData: Data?

var body: some View {
    NavigationStack(path: $router.path) { ... }
        .onAppear { router.restore(from: pathData) }
        .onChange(of: router.path) { pathData = router.serialize() }
}
```

---

## Essential Patterns

### Type-Safe Router

```swift
@MainActor
final class Router: ObservableObject {
    enum Route: Hashable {
        case userList
        case userDetail(userId: String)
        case settings
        case settingsSection(SettingsSection)
    }

    @Published var path: [Route] = []

    func navigate(to route: Route) {
        path.append(route)
    }

    func pop() {
        guard !path.isEmpty else { return }
        path.removeLast()
    }

    func popToRoot() {
        path.removeAll()
    }

    func replaceStack(with routes: [Route]) {
        path = routes
    }

    @ViewBuilder
    func destination(for route: Route) -> some View {
        switch route {
        case .userList:
            UserListView()
        case .userDetail(let userId):
            UserDetailView(userId: userId)
        case .settings:
            SettingsView()
        case .settingsSection(let section):
            SettingsSectionView(section: section)
        }
    }
}
```

### Deep Link Handler

```swift
enum DeepLink {
    case user(id: String)
    case product(id: String)
    case settings

    init?(url: URL) {
        guard let scheme = url.scheme,
              ["myapp", "https"].contains(scheme) else { return nil }

        let path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
        let components = path.components(separatedBy: "/")

        switch components.first {
        case "user":
            guard components.count > 1 else { return nil }
            self = .user(id: components[1])
        case "product":
            guard components.count > 1 else { return nil }
            self = .product(id: components[1])
        case "settings":
            self = .settings
        default:
            return nil
        }
    }
}

extension Router {
    func handle(_ deepLink: DeepLink) {
        popToRoot()

        switch deepLink {
        case .user(let id):
            navigate(to: .userList)
            navigate(to: .userDetail(userId: id))
        case .product(let id):
            navigate(to: .productDetail(productId: id))
        case .settings:
            navigate(to: .settings)
        }
    }
}
```

### Tab + Navigation Coordination

```swift
struct MainTabView: View {
    @State private var selectedTab: Tab = .home
    @StateObject private var homeRouter = Router()
    @StateObject private var profileRouter = Router()

    enum Tab { case home, search, profile }

    var body: some View {
        TabView(selection: $selectedTab) {
            NavigationStack(path: $homeRouter.path) {
                HomeView()
                    .navigationDestination(for: Router.Route.self) { route in
                        homeRouter.destination(for: route)
                    }
            }
            .tag(Tab.home)
            .environmentObject(homeRouter)

            NavigationStack(path: $profileRouter.path) {
                ProfileView()
                    .navigationDestination(for: Router.Route.self) { route in
                        profileRouter.destination(for: route)
                    }
            }
            .tag(Tab.profile)
            .environmentObject(profileRouter)
        }
    }

    // Pop to root on tab re-selection
    func tabSelected(_ tab: Tab) {
        if selectedTab == tab {
            switch tab {
            case .home: homeRouter.popToRoot()
            case .profile: profileRouter.popToRoot()
            case .search: break
            }
        }
        selectedTab = tab
    }
}
```

---

## Quick Reference

### Navigation Architecture Comparison

| Pattern | Complexity | Deep Link Support | Testability |
|---------|------------|-------------------|-------------|
| Inline NavigationLink | Low | Manual | Low |
| Router with typed array | Medium | Good | High |
| NavigationPath | Medium | Good | Medium |
| Coordinator Pattern | High | Excellent | Excellent |

### When to Use Each Modal Type

| Modal Type | Use For |
|------------|---------|
| `.sheet` | Secondary tasks, can dismiss |
| `.fullScreenCover` | Immersive flows (onboarding, login) |
| `.alert` | Critical decisions |
| `.confirmationDialog` | Action choices |

### Red Flags

| Smell | Problem | Fix |
|-------|---------|-----|
| NavigationPath across tabs | State confusion | Per-tab routers |
| View creates destination directly | Tight coupling | Router pattern |
| String-based routing | No compile safety | Enum routes |
| Deep link ignored on cold start | Race condition | Pending URL queue |
| ViewModel owns NavigationPath | Layer violation | Router owns navigation |
| No popToRoot on tab re-tap | UX expectation | Handle tab selection |

Overview

This skill advises on choosing and implementing navigation patterns for iOS and tvOS apps, focusing on NavigationStack vs Coordinator, NavigationPath trade-offs, deep link handling, and tab + navigation coordination. It delivers practical rules, decision trees, and code-guided guidance to design predictable, testable navigation. Use it when designing app navigation architecture, implementing deep links, or debugging navigation-state issues.

How this skill works

The skill inspects your app's navigation complexity and recommends patterns: inline NavigationLink for simple linear flows, a typed Router with a typed array for medium complexity, and a tab-coordinator with per-tab routers for complex multi-stack apps. It evaluates whether you need type-erased NavigationPath or type-safe route enums, how to queue and handle deep links across cold and warm starts, and when to present modals vs push navigation. It also highlights anti-patterns and provides safe defaults for state ownership and persistence.

When to use it

  • Designing high-level navigation architecture for a new SwiftUI app
  • Deciding between NavigationPath and a type-safe route enum
  • Implementing or debugging deep link routing across cold/warm starts
  • Coordinating tabs that require independent navigation stacks
  • Refactoring navigation-coupled view models into router/coordinator patterns

Best practices

  • Prefer type-safe route enums and typed arrays unless you truly need heterogeneous route types
  • Keep NavigationPath or navigation state inside a Router/Coordinator, not business ViewModels
  • Give each tab its own Router and NavigationPath to avoid cross-tab state leaks
  • Queue deep links received before the view hierarchy exists and handle them onAppear
  • Persist critical navigation state only when UX demands restoring user context

Example use cases

  • A single-flow utility app: use NavigationStack with inline NavigationLink, no router
  • An app with deep linking and multiple flows: Router with @Published typed path and centralized handler
  • A tabbed app with cross-tab navigation: Tab coordinator with per-tab routers and popToRoot on re-selection
  • Handling universal links: parse URL into DeepLink enum, queue on cold start, then router.handle(deepLink)
  • Migrating a ViewModel that owns NavigationPath: extract navigation into a Router and keep ViewModel data-only

FAQ

When should I use NavigationPath instead of a typed route array?

Use NavigationPath only if you must store heterogeneous types in one stack. Prefer typed arrays for compile-time safety, easier debugging, and serialization.

Where should deep link logic live?

Centralize parsing and handling in a Router or DeepLink handler. Queue links on cold start and apply them when the root view hierarchy is ready.