5.5 — Navigation

Opening scenario

You inherited a SwiftUI app from 2022. The codebase is full of NavigationView, NavigationLink(isActive:), and Booleans named isProfilePresented, isSettingsPresented, isShippingPresented — one per destination. Deep linking is a switch statement inside .onOpenURL that toggles seven flags in sequence with DispatchQueue.main.asyncAfter delays to “make sure navigation completes.” When two pushes happen close together, the second silently fails. The QA log lists 14 navigation bugs.

Apple deprecated NavigationView and the isActive: pattern for exactly this reason. iOS 16 introduced NavigationStack and NavigationSplitViewvalue-driven navigation. Your routes become data; you push a value, SwiftUI looks up the destination, navigation works deterministically. Deep linking becomes “set the navigation path to [.profile, .settings]” — atomic, testable, no flags.

APIEraUse for
NavigationViewiOS 13–15Deprecated. Don’t write new code with it.
NavigationLink(isActive:)iOS 13–15Deprecated. Bug-prone.
NavigationStackiOS 16+Single-column push/pop navigation (iPhone, iPad portrait)
NavigationSplitViewiOS 16+Multi-column sidebar/list/detail (iPad, macOS, large iPhones in landscape)
navigationDestination(for:)iOS 16+Map a value type to a destination view
NavigationPathiOS 16+Type-erased path for deep linking
.sheet/.fullScreenCoveriOS 13+Modal presentation (not navigation, conceptually)

Concept → Why → How → Code

The pre-iOS-16 problem

// OLD — don't do this
struct ContentView: View {
    @State private var isProfileActive = false
    @State private var isSettingsActive = false

    var body: some View {
        NavigationView {
            VStack {
                NavigationLink("Profile", isActive: $isProfileActive) {
                    ProfileView()
                }
                NavigationLink("Settings", isActive: $isSettingsActive) {
                    SettingsView()
                }
            }
        }
    }
}

Problems:

  • One flag per destination — N flags for N destinations
  • No central “where am I in the navigation stack?”
  • Two pushes in quick succession race
  • Deep linking is a chain of Bool.toggle()s with manual delays
  • Hard to test (“what should be on screen?” answer requires inspecting many flags)
struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("Profile", value: Route.profile)
                NavigationLink("Settings", value: Route.settings)
            }
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .profile: ProfileView()
                case .settings: SettingsView()
                }
            }
        }
    }
}

enum Route: Hashable {
    case profile, settings
}
  • NavigationLink(_, value:) pushes a value onto the stack
  • .navigationDestination(for: T.self) { value in ... } declares how to render a T
  • The stack is a list of values; push appends, pop removes the last
  • Multiple navigation destinations per type are supported (declare separately by type)

Programmatic navigation with NavigationPath

For deep linking and explicit control:

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

    var body: some View {
        NavigationStack(path: $path) {
            HomeView()
                .navigationDestination(for: Route.self) { route in
                    destination(for: route)
                }
        }
        .onOpenURL { url in
            // Deep link: /profile/settings → push profile then settings
            path.append(Route.profile)
            path.append(Route.settings)
        }
    }
}

NavigationPath is a type-erased container — it can hold any Hashable and Codable values. You can mix value types in one stack:

path.append(Route.profile)
path.append(Item(id: "x", title: "Document"))  // a different type!

Both need separate .navigationDestination(for:) modifiers — one for Route, one for Item. SwiftUI dispatches by value type.

Typed path for better APIs

If your navigation is homogeneous, use [Route] directly:

struct AppRoot: View {
    @State private var path: [Route] = []

    var body: some View {
        NavigationStack(path: $path) {
            HomeView()
                .navigationDestination(for: Route.self) { route in
                    destination(for: route)
                }
        }
    }
}

// Push: path.append(.profile)
// Pop: path.removeLast()
// Pop all: path.removeAll()
// Pop to specific: path = [.home, .profile]

Programmatic operations are array operations. Testable.

For iPad and macOS, you want a sidebar + content + detail layout:

struct ContentView: View {
    @State private var selectedFolder: Folder?
    @State private var selectedNote: Note?

    var body: some View {
        NavigationSplitView {
            // Sidebar
            List(folders, selection: $selectedFolder) { folder in
                Text(folder.name).tag(folder)
            }
        } content: {
            // Middle column: notes in the selected folder
            if let folder = selectedFolder {
                List(folder.notes, selection: $selectedNote) { note in
                    Text(note.title).tag(note)
                }
            } else {
                Text("Select a folder")
            }
        } detail: {
            // Detail column
            if let note = selectedNote {
                NoteEditor(note: note)
            } else {
                Text("Select a note")
            }
        }
    }
}

NavigationSplitView adapts:

  • Mac / iPad landscape: three columns visible
  • iPad portrait: sidebar collapses to overlay
  • iPhone: collapses to a NavigationStack-equivalent

Three flavors: 2-column (sidebar | detail) or 3-column (sidebar | content | detail).

columnVisibility parameter controls which columns show by default.

Combining NavigationSplitView + NavigationStack

In the detail column, you can have its own push/pop stack:

NavigationSplitView {
    Sidebar(selection: $selection)
} detail: {
    NavigationStack(path: $detailPath) {
        DetailRoot(selection: selection)
            .navigationDestination(for: Route.self) { ... }
    }
}

Each navigation context (split sidebar, split detail, sheet) can have its own NavigationStack with its own path binding. Pushes in the detail stack don’t affect the sidebar.

Modals: .sheet, .fullScreenCover, .popover

Modals are not navigation — they present a view on top of the current context. Same value-driven pattern works:

struct InboxView: View {
    @State private var composing: Draft?

    var body: some View {
        List(messages) { msg in
            Text(msg.subject)
        }
        .toolbar {
            Button("Compose") {
                composing = Draft()
            }
        }
        .sheet(item: $composing) { draft in
            ComposeView(draft: draft)
        }
    }
}
  • sheet(item:) shows when the item is non-nil; dismisses when set to nil
  • Works with any Identifiable value
  • Versus sheet(isPresented:) (Boolean) — prefer item: for passing context

.fullScreenCover(item:) covers the whole screen with no swipe-to-dismiss (use sparingly — iOS users expect swipe-down).

Dismiss from a child view

struct ComposeView: View {
    @Environment(\.dismiss) private var dismiss
    var body: some View {
        Button("Cancel") { dismiss() }
    }
}

@Environment(\.dismiss) works for sheets, full-screen covers, and pushes — dismisses whatever current presentation context the view is in.

Centralized router pattern

For non-trivial apps, centralize navigation in an @Observable router:

@Observable
@MainActor
final class AppRouter {
    var homePath: [HomeRoute] = []
    var profilePath: [ProfileRoute] = []
    var presentedSheet: Sheet?

    enum HomeRoute: Hashable {
        case product(Product.ID)
        case category(Category)
    }

    enum ProfileRoute: Hashable {
        case settings, editProfile, helpCenter
    }

    enum Sheet: Identifiable {
        case auth, debug
        var id: String { String(describing: self) }
    }

    func openProduct(_ id: Product.ID) {
        homePath = [.product(id)]
    }

    func handleDeepLink(_ url: URL) {
        // Parse URL → set appropriate path
    }
}

@main
struct App: App {
    @State private var router = AppRouter()
    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(router)
                .onOpenURL { router.handleDeepLink($0) }
        }
    }
}

Benefits:

  • Single place to inspect “where is the user?” — useful for analytics, restoration
  • Deep linking is a method call, no race conditions
  • Tab switching + navigation reset becomes one atomic operation
  • Testable: assert router state after action

Deep linking — the right way

// URL: myapp://product/42
func handleDeepLink(_ url: URL) {
    guard url.scheme == "myapp" else { return }
    let parts = url.pathComponents.filter { $0 != "/" }
    switch parts.first {
    case "product":
        if let idStr = parts[safe: 1], let id = Product.ID(idStr) {
            selectedTab = .home
            homePath = [.product(id)]
        }
    case "settings":
        selectedTab = .profile
        profilePath = [.settings]
    default:
        return
    }
}

Atomic — set the tab and path in the same run loop turn. No flags, no delays.

For universal links (HTTPS-based, App-bound), same pattern via onContinueUserActivity:

.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
    if let url = activity.webpageURL { handleDeepLink(url) }
}

Toolbar items

NavigationStack {
    ProfileView()
        .navigationTitle("Profile")
        .navigationBarTitleDisplayMode(.inline)   // or .large / .automatic
        .toolbar {
            ToolbarItem(placement: .topBarLeading) {
                Button("Cancel") { dismiss() }
            }
            ToolbarItem(placement: .topBarTrailing) {
                Button("Save") { save() }
            }
        }
}

Placements: .topBarLeading, .topBarTrailing, .principal (centered), .bottomBar, .keyboard (above keyboard), .navigationBarLeading/Trailing (legacy). Use semantic placements; SwiftUI adapts per platform.

What about tabs?

Tabs are orthogonal to navigation — each tab can host its own NavigationStack:

TabView(selection: $selectedTab) {
    NavigationStack(path: $homePath) { HomeView() }
        .tabItem { Label("Home", systemImage: "house") }
        .tag(Tab.home)
    NavigationStack(path: $profilePath) { ProfileView() }
        .tabItem { Label("Profile", systemImage: "person") }
        .tag(Tab.profile)
}

iOS 18 introduced TabView with Tab API (Tab("Home", systemImage: "house") { ... }) — cleaner syntax, supports floating tab bar on iPad. Use the new API on iOS 18+.

In the wild

  • Apple Notes (iOS 16+) uses NavigationSplitView extensively; the same codebase adapts iPhone (collapsed stack) and iPad (3 columns).
  • Apollo (RIP) centralized its router in an observable; deep linking from notifications was a single method call.
  • Stripe Dashboard uses a typed Route enum per tab and stores paths in their flow coordinator.
  • Apple Reminders uses NavigationSplitView with custom column visibility per orientation.

Common misconceptions

  1. NavigationView still works, don’t bother migrating.” It’s deprecated and gets less attention each release. New APIs (.navigationDestination, NavigationPath) don’t work inside NavigationView. Migrate when you touch the file.
  2. NavigationLink is the only way to push.” Programmatic push (path.append(...)) is fully supported and necessary for deep linking, post-action navigation, and tests.
  3. NavigationPath and [Route] are different.” They serve the same goal; [Route] gives you compile-time type safety, NavigationPath allows mixed types. Use the typed array unless you need heterogeneity.
  4. “Sheets are part of navigation.” Modals are presented on top of a navigation context. They have their own dismiss semantics. Don’t push views via sheets; use a NavigationStack inside the sheet if you need internal navigation.
  5. “Deep linking needs DispatchQueue.main.asyncAfter.” With value-driven navigation, deep links are atomic — set the path and tab in one synchronous block.

Seasoned engineer’s take

Centralize your navigation in a router object as soon as your app has more than ~10 destinations or any deep linking. The benefits are huge: analytics (router.didChangePath), state restoration (encode the path), tests (assert path after action), and the bug class of “two flags set, race, ambiguous state” disappears.

Keep modals separate from push navigation in your router. Sheets/full-screen covers are presentation events, not destinations on a stack. A common smell: routers with path mixing sheet routes and push routes. Split them.

For multiplatform (iPhone + iPad + macOS), use NavigationSplitView and let SwiftUI adapt. Don’t try to detect platform and switch between NavigationStack and NavigationSplitView; that path has subtle bugs.

TIP: Make your Route enum Codable (in addition to Hashable). Then you can persist path to disk (or restore from notification payload) trivially: encode/decode as JSON. State restoration becomes free.

WARNING: Don’t use NavigationLink { ... } label: { ... } (closure-based) in deep navigation. It eagerly initializes the destination — wasteful and reads state for views the user may never see. Use NavigationLink(_, value:) + .navigationDestination(for:) for lazy initialization.

Interview corner

Junior-level: “What replaced NavigationView and why?”

NavigationStack (for single-column) and NavigationSplitView (for multi-column). NavigationView was deprecated because it had subtle issues with programmatic navigation, mixed iPhone/iPad behavior, and the NavigationLink(isActive:) pattern was bug-prone. The new APIs introduced value-driven navigation: push values, register destinations by type, control the stack as data.

Mid-level: “How would you implement deep linking from a push notification in a SwiftUI app?”

Centralize navigation state in an @Observable router with one path per tab and a current tab selection. On notification tap, parse the payload, then set the router’s tab and path atomically (router.selectedTab = .messages; router.messagesPath = [.conversation(id), .messageDetail(messageID)]). SwiftUI re-renders, the stack reconstructs, the user lands on the right view. No flags, no async delays. Make routes Codable so the path round-trips through the notification payload.

Senior-level: “You have a tab-based app with 4 tabs, each its own NavigationStack. The user is deep in Profile → Settings → Privacy. They tap a push notification that should take them to Messages → Conversation 42 → Message 17. What’s the implementation, and what edge cases do you handle?”

Centralized router with per-tab paths:

@Observable @MainActor
final class Router {
    var selectedTab: Tab = .home
    var paths: [Tab: [Route]] = [:]
}

On notification tap, parse payload → call router.openConversation(id: 42, focusMessage: 17):

func openConversation(id: ConversationID, focusMessage: MessageID?) {
    selectedTab = .messages
    var path: [Route] = [.conversation(id)]
    if let mid = focusMessage { path.append(.message(mid)) }
    paths[.messages] = path
}

Edge cases:

  • App is killed: application(_:didFinishLaunching...) checks launchOptions[.remoteNotification]; defer the navigation until SwiftUI hierarchy is up (.onAppear on root, or .task with a small delay only if needed).
  • App is backgrounded: onChange(of: scenePhase) handle pending deep links queued while inactive.
  • Modal presented: dismiss modals first, then navigate (router can dismiss via presentedSheet = nil).
  • Route doesn’t exist (e.g., conversation deleted): navigate to fallback (the conversations list), show a toast.
  • User is in onboarding: queue the deep link, replay after onboarding completes.

Test by writing unit tests that call router methods and assert the resulting state — no view hierarchy needed.

Red flag in candidates: Using DispatchQueue.main.asyncAfter to “make sure navigation completed” before deep-linking. Indicates fighting the framework rather than using value-driven navigation properly.

Lab preview

Lab 5.1 uses NavigationStack with a typed path; Lab 5.3 uses NavigationSplitView for the iPad/macOS split UI.


Next: Lists, forms & grids