Skip to main content
Technology & EngineeringIos Swift335 lines

Navigation

SwiftUI navigation patterns using NavigationStack, programmatic routing, and deep linking

Quick Summary14 lines
You are an expert in navigation patterns for building iOS applications with Swift and SwiftUI.

## Key Points

- **Use `NavigationStack` with a `NavigationPath` for programmatic control.** Avoid the legacy `NavigationView` and the `isActive`/`selection` binding patterns.
- **Wrap each tab's content in its own `NavigationStack`.** Placing `NavigationStack` inside `TabView` (not outside) prevents tabs from sharing navigation state.
- **Centralize routing logic** in a router or coordinator for apps with deep linking, notifications, or complex flows. This keeps views focused on presentation.
- **Use `navigationDestination(for:)` to map types to views.** Register one destination per type at the appropriate level in the hierarchy — usually on the list that pushes the detail.
- **Prefer value-based `NavigationLink(value:)`** over view-based `NavigationLink(destination:)`. The value-based API enables programmatic push and type-safe routing.
- **Use `.navigationTitle` and `.toolbar` on the inner view**, not on the `NavigationStack`. Modifiers on child views propagate up to the container.
- **Nesting `NavigationStack` inside `NavigationStack`.** This creates a double navigation bar. Each navigation hierarchy should have exactly one stack at the root.
- **State loss on tab switch.** If navigation paths are stored in `@State` inside a tab's content view, they reset when switching tabs. Lift path state into a shared model or the router.
skilldb get ios-swift-skills/NavigationFull skill: 335 lines
Paste into your CLAUDE.md or agent config

Navigation — iOS/Swift

You are an expert in navigation patterns for building iOS applications with Swift and SwiftUI.

Core Philosophy

Navigation in SwiftUI should be data-driven, not imperative. The old UIKit pattern of "push this view controller onto the stack" has been replaced by a model where navigation state is a collection of values, and the framework renders whatever screens those values describe. This shift means navigation becomes testable -- you can verify that your app navigates to the right screen by checking the path array, without rendering a single view. It also means deep linking becomes trivial: parse a URL into values, set the path, and the navigation stack reflects the URL.

The guiding principle is to keep navigation concerns out of leaf views. A detail screen should not know how it was presented or what it should push next. Instead, it reports user intent through callbacks or actions, and a coordinator or router at a higher level translates those intents into navigation mutations. This separation means you can reuse the same screen in a stack, a sheet, or a split view without modifying it.

State ownership matters for navigation just as it does for any other state. If the path is stored in @State inside a view that gets recreated (such as a tab's content view), the path resets unexpectedly. Navigation paths that need to survive tab switches, sheet dismissals, or app backgrounding must be lifted into a shared model at the appropriate level -- typically an @Observable router or a persistent store for state restoration.

Anti-Patterns

  • Nesting NavigationStack inside NavigationStack: This creates a double navigation bar and confusing push behavior. Each navigation hierarchy should have exactly one stack at its root. Sheets and split views create their own contexts, but within a single navigation flow, there must be one stack.

  • Passing the NavigationPath deep into the view hierarchy: When leaf views directly manipulate the path, navigation logic scatters across the entire codebase. Instead, pass navigation callbacks (closures) or use an environment-injected router so leaf views remain unaware of the navigation mechanism.

  • Using the deprecated NavigationView and isActive binding pattern: The NavigationView API with isActive and selection bindings is fragile, poorly documented, and replaced by NavigationStack in iOS 16. Using it in new code creates technical debt and limits your ability to programmatically control navigation.

  • Hardcoding navigation structure into views: A view that contains NavigationLink(destination: SpecificDetailView()) is tightly coupled to both the navigation mechanism and the destination. Use value-based NavigationLink(value:) with navigationDestination(for:) so the mapping from values to views lives in one place.

  • Processing deep links before the view hierarchy is ready: Appending values to a NavigationPath before the NavigationStack has finished its initial render can silently fail. Defer deep link processing to after the root view appears, or queue the link and apply it in an .onAppear or .task modifier.

Overview

SwiftUI's navigation system has evolved significantly. NavigationStack (iOS 16+) replaced NavigationView with a data-driven, programmatic model. Navigation state is represented by a collection of values (a path), enabling deep linking, state restoration, and complex routing logic. For tab-based and split-view apps, TabView and NavigationSplitView complement the stack model.

Core Concepts

NavigationStack and NavigationLink

NavigationStack manages a stack of views driven by a path of values:

struct ContentView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            List {
                NavigationLink("Recipes", value: Route.recipes)
                NavigationLink("Favorites", value: Route.favorites)
                NavigationLink("Settings", value: Route.settings)
            }
            .navigationTitle("Home")
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .recipes: RecipeListView(path: $path)
                case .favorites: FavoritesView()
                case .settings: SettingsView()
                }
            }
            .navigationDestination(for: Recipe.self) { recipe in
                RecipeDetailView(recipe: recipe)
            }
        }
    }
}

enum Route: Hashable {
    case recipes
    case favorites
    case settings
}

Programmatic Navigation

Manipulating the path directly enables push, pop, and deep-link scenarios:

struct RecipeListView: View {
    @Binding var path: NavigationPath
    let recipes: [Recipe]

    var body: some View {
        List(recipes) { recipe in
            NavigationLink(value: recipe) {
                RecipeRow(recipe: recipe)
            }
        }
        .navigationTitle("Recipes")
        .toolbar {
            Button("Go to featured") {
                if let featured = recipes.first(where: { $0.isFeatured }) {
                    path.append(featured)
                }
            }
        }
    }

    func popToRoot() {
        path = NavigationPath()
    }

    func popLast() {
        if !path.isEmpty {
            path.removeLast()
        }
    }
}

NavigationSplitView (iPad / Mac)

For multi-column layouts on larger screens:

struct MailView: View {
    @State private var selectedFolder: Folder?
    @State private var selectedMessage: Message?

    var body: some View {
        NavigationSplitView {
            List(Folder.allCases, selection: $selectedFolder) { folder in
                Label(folder.name, systemImage: folder.icon)
            }
            .navigationTitle("Mail")
        } content: {
            if let folder = selectedFolder {
                MessageListView(folder: folder, selection: $selectedMessage)
            } else {
                ContentUnavailableView("Select a Folder", systemImage: "folder")
            }
        } detail: {
            if let message = selectedMessage {
                MessageDetailView(message: message)
            } else {
                ContentUnavailableView("Select a Message", systemImage: "envelope")
            }
        }
        .navigationSplitViewStyle(.balanced)
    }
}

TabView

struct MainTabView: View {
    @State private var selectedTab = Tab.home

    enum Tab: Hashable {
        case home, search, profile
    }

    var body: some View {
        TabView(selection: $selectedTab) {
            NavigationStack {
                HomeView()
            }
            .tabItem {
                Label("Home", systemImage: "house")
            }
            .tag(Tab.home)

            NavigationStack {
                SearchView()
            }
            .tabItem {
                Label("Search", systemImage: "magnifyingglass")
            }
            .tag(Tab.search)

            NavigationStack {
                ProfileView()
            }
            .tabItem {
                Label("Profile", systemImage: "person")
            }
            .tag(Tab.profile)
        }
    }
}

Implementation Patterns

Router Pattern

Centralize navigation logic in a router for testability and deep linking:

@Observable
class Router {
    var homePath = NavigationPath()
    var searchPath = NavigationPath()
    var selectedTab: Tab = .home

    enum Tab: Hashable {
        case home, search, profile
    }

    func navigate(to destination: any Hashable) {
        switch selectedTab {
        case .home: homePath.append(destination)
        case .search: searchPath.append(destination)
        case .profile: break
        }
    }

    func popToRoot() {
        switch selectedTab {
        case .home: homePath = NavigationPath()
        case .search: searchPath = NavigationPath()
        case .profile: break
        }
    }

    // Deep link handling
    func handleDeepLink(_ url: URL) {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
              let host = components.host else { return }

        switch host {
        case "recipe":
            if let id = components.queryItems?.first(where: { $0.name == "id" })?.value {
                selectedTab = .home
                homePath = NavigationPath()
                homePath.append(Route.recipeDetail(id: id))
            }
        case "search":
            selectedTab = .search
            searchPath = NavigationPath()
            if let query = components.queryItems?.first(where: { $0.name == "q" })?.value {
                searchPath.append(Route.searchResults(query: query))
            }
        default:
            break
        }
    }
}

// Usage in App
@main
struct MyApp: App {
    @State private var router = Router()

    var body: some Scene {
        WindowGroup {
            MainTabView()
                .environment(router)
                .onOpenURL { url in
                    router.handleDeepLink(url)
                }
        }
    }
}

Sheet and Full-Screen Cover Navigation

struct ItemListView: View {
    @State private var presentedItem: Item?
    @State private var showNewItemSheet = false

    var body: some View {
        List(items) { item in
            Button(item.title) {
                presentedItem = item
            }
        }
        .toolbar {
            Button("Add", systemImage: "plus") {
                showNewItemSheet = true
            }
        }
        .sheet(item: $presentedItem) { item in
            NavigationStack {
                ItemDetailView(item: item)
            }
        }
        .sheet(isPresented: $showNewItemSheet) {
            NavigationStack {
                NewItemView()
            }
            .interactiveDismissDisabled()
        }
    }
}

State Restoration with Codable NavigationPath

@Observable
class NavigationStore {
    var path = NavigationPath()

    private let savePath = URL.documentsDirectory.appending(path: "navigation.json")

    func save() {
        guard let representation = path.codable else { return }
        do {
            let data = try JSONEncoder().encode(representation)
            try data.write(to: savePath)
        } catch {
            print("Failed to save navigation state: \(error)")
        }
    }

    func restore() {
        guard let data = try? Data(contentsOf: savePath),
              let decoded = try? JSONDecoder().decode(
                  NavigationPath.CodableRepresentation.self, from: data
              ) else { return }
        path = NavigationPath(decoded)
    }
}

Best Practices

  • Use NavigationStack with a NavigationPath for programmatic control. Avoid the legacy NavigationView and the isActive/selection binding patterns.
  • Wrap each tab's content in its own NavigationStack. Placing NavigationStack inside TabView (not outside) prevents tabs from sharing navigation state.
  • Centralize routing logic in a router or coordinator for apps with deep linking, notifications, or complex flows. This keeps views focused on presentation.
  • Use navigationDestination(for:) to map types to views. Register one destination per type at the appropriate level in the hierarchy — usually on the list that pushes the detail.
  • Prefer value-based NavigationLink(value:) over view-based NavigationLink(destination:). The value-based API enables programmatic push and type-safe routing.
  • Use .navigationTitle and .toolbar on the inner view, not on the NavigationStack. Modifiers on child views propagate up to the container.

Common Pitfalls

  • Nesting NavigationStack inside NavigationStack. This creates a double navigation bar. Each navigation hierarchy should have exactly one stack at the root.
  • Missing navigationDestination registration. If a NavigationLink(value:) pushes a type that has no registered destination, the link appears disabled. Ensure destinations are registered in the same NavigationStack scope.
  • State loss on tab switch. If navigation paths are stored in @State inside a tab's content view, they reset when switching tabs. Lift path state into a shared model or the router.
  • Sheets losing navigation context. Sheets create a new presentation context. A NavigationStack inside a sheet is independent from the parent stack. If you need navigation within a sheet, add a NavigationStack inside it explicitly.
  • Deep link race conditions. Processing a deep link before the view hierarchy is fully loaded can silently fail. Defer deep link handling to after the root view appears using .onAppear or a small delay.

Install this skill directly: skilldb add ios-swift-skills

Get CLI access →