iOS – Navigation

July 29, 20268 min readUpdated 9/18/2026

Navigation is where SwiftUI apps most often end up with code nobody wants to touch. The framework has had three generations of API in five years, most tutorials still show the oldest, and the shape that scales is not the shape you reach for first.

This lesson is the modern one: a stack driven by a path of values, typed routes, and a tab bar where each tab remembers where it was.

Why routes should be values

The API almost everyone meets first is NavigationLink(destination:), which takes the destination view directly. It works, and it has a flaw that only shows up at size: the destination is built eagerly, at the moment the link is rendered. A list of fifty orders constructs fifty detail screens nobody asked for.

NavigationStack with a path inverts that. You append a small value; the view is built only when it is actually shown:

enum AppRoute: Hashable {
    case checkout
    case order(id: UUIDString)
}

Two lines, and both destinations the app can push. Hashable is the only requirement — the stack uses it to tell entries apart.

The second payoff is bigger than the first: navigation becomes data. "The customer is two levels deep looking at order X" is a [AppRoute], which can be asserted on in a test, logged with a bug report, or restored after the system terminated your app in the background. None of that is possible when the position is implied by which views happen to be alive.

The stack

                NavigationStack(path: router.path(for: tab)) {
                    rootScreen(for: tab)
                        .navigationDestination(for: AppRoute.self) { route in
                            destination(for: route)
                        }

Three pieces. The path binding is the stack's contents, and it is two-way — the system writes to it when the customer swipes back, so your model always reflects the truth. navigationDestination(for:) registers how to turn a route into a view. And the closure runs lazily, once, when that route is pushed.

Turning a route into a screen is then an ordinary switch:

    @ViewBuilder
    private func destination(for route: AppRoute) -> some View {
        switch route {
        case .checkout:
            CheckoutView()
        case let .order(id):
            OrderConfirmationView(orderID: id)
        }
    }

Exhaustive, so adding a route stops the build until somebody says what it shows. @ViewBuilder is what allows the branches to return different view types.

Where to register the destination

A trap worth naming: navigationDestination must be attached inside the NavigationStack, on a view in its content — not on the stack itself. Put it on the stack and pushes do nothing at all, with a purple runtime warning if you are lucky and silence if you are not.

Tabs, and why each needs its own stack

A tab bar is a TabView, and every tab holds a NavigationStack. The question people get wrong is whether those stacks share a path.

They must not. One shared path means switching tabs inherits the previous tab's stack — tap Orders, open an order, switch to Menu, and the menu appears two levels deep with a back button to somebody else's screen. Per-tab paths are what make each tab remember where it was, which is what every iOS user expects without being able to articulate it.

@MainActor
@Observable
final class AppRouter {
    var selectedTab: AppTab = .home
    var presentedSheet: AppSheet?

    var homePath = NavigationPath()
    var menuPath = NavigationPath()
    var ordersPath = NavigationPath()
    var profilePath = NavigationPath()

NavigationPath is a type-erased stack, so it can hold routes of several different types at once. A plain [AppRoute] also works and is simpler when — as here — there is only one route type; the erased version costs nothing and leaves the door open.

Routing without knowing which tab you are in

Four paths means four places to append to, and no feature should have to care which. One method resolves it:

    func push(_ route: AppRoute) {
        switch selectedTab {
        case .home: homePath.append(route)
        case .menu: menuPath.append(route)
        case .orders: ordersPath.append(route)
        case .profile: profilePath.append(route)
        }
    }

Now the checkout button on the cart sheet works identically whether the customer opened the cart from Home or from Orders. The feature says router.push(.checkout) and nothing else.

Programmatic navigation

Because the path is just a value, moving around is ordinary code. Three operations cover almost everything.

Push appends, as above. Pop to root assigns an empty path. And replace — the one people reach for late and need more than they expect:

    func replaceStack(with route: AppRoute) {
        switch selectedTab {
        case .home: homePath = NavigationPath([route])
        case .menu: menuPath = NavigationPath([route])
        case .orders: ordersPath = NavigationPath([route])
        case .profile: profilePath = NavigationPath([route])
        }
    }

This is what runs after a successful payment. Pushing the confirmation screen would leave checkout underneath it, and the back gesture would return the customer to a payment form for an order that has already been paid for. Clearing the stack first is the native equivalent of a web router's replace, and the reason to have it is a real bug rather than tidiness.

Dismissing from inside a screen

A pushed screen that wants to go back does not need the router at all:

    @Environment(\.dismiss) private var dismiss

Calling dismiss() pops a pushed view or closes a sheet, whichever it is in. Prefer it for "close me" and keep the router for "go somewhere specific" — a screen that knows how to dismiss itself is reusable in both contexts.

Tabs as a closed set

The tabs themselves are an enum, so the tab bar is generated rather than listed:

enum AppTab: Hashable, CaseIterable {
    case home
    case menu
    case orders
    case profile

    var title: String {
        switch self {
        case .home: "Home"
        case .menu: "Menu"
        case .orders: "Orders"
        case .profile: "Profile"
        }
    }

Which means the bar itself is a loop:

                .tabItem {
                    Label(tab.title, systemImage: tab.systemImage)
                }
                .tag(tab)

Adding a fifth tab is then one enum case and one switch arm the compiler asks for, rather than four edits in three files that are easy to leave half-finished.

.tag is what connects a tab to the selection binding, and omitting it is a common cause of "my tab bar does not switch". Label takes an SF Symbol name — a system icon set that tints with the tab bar's colours, scales with Dynamic Type and carries accessibility descriptions. The React Native version of this app uses emoji and comments that a real app would use an icon set; this is that app.

What is deliberately not a tab

The cart. It is a toolbar button that opens a sheet, so the customer never loses the screen they were on — tapping a fifth tab to check the basket and then navigating back to the menu is the friction this avoids. Worth stating because "is this a destination or an overlay?" is a design decision that navigation code will otherwise make for you by default.

Modals belong in the same model

A sheet is navigation too, and the instinct is to give each one a boolean. Three @State private var isXPresented flags allow two sheets to be "presented" at once, which SwiftUI resolves by showing one and silently ignoring the other — a bug that looks like a tap being dropped. One optional enum makes that unrepresentable:

enum AppSheet: Identifiable, Hashable {
    case cart
    case signIn
    case register

    var id: Self { self }
}

var id: Self { self } is the idiom for making an enum Identifiable when the case itself is the identity. Lesson 8 goes into what .sheet(item:) then buys you; the point here is that modal presentation lives in the router beside the stacks, because it is the same question.

The navigation bar

The bar is configured from the content, not from the stack — another consequence of views being values. A screen states what it wants and the nearest stack obeys:

                        .toolbar { cartToolbarItem }
                        .toolbarBackground(Theme.colors.surfaceInverse, for: .navigationBar)
                        .toolbarBackground(.visible, for: .navigationBar)
                        .toolbarColorScheme(.dark, for: .navigationBar)

Two of those need explaining. .toolbarBackground(.visible, …) is required because iOS hides the bar's background until content scrolls under it — without it, a dark brand bar appears only when the customer scrolls, which reads as a rendering bug. And .toolbarColorScheme(.dark, …) tells the system the bar is dark so it draws the title and buttons light; it is about the bar, not the app.

Titles are set per screen with .navigationTitle(), and .navigationBarTitleDisplayMode(.inline) chooses between the large scrolling title and the compact one. Both belong on the screen, for the same reason: the screen knows what it is called, the container does not.

Because routes are values, a URL handler is a parser and nothing more: turn the URL into an AppRoute, select the right tab, append. onOpenURL is where that goes.

The demo app declares a URL scheme already — not for deep linking, but because Stripe needs somewhere to return to after a bank's 3D Secure page. That is lesson 17; the point here is that the scheme is registered in Info.plist and the routing model is ready for it.

What to avoid

Two patterns that look reasonable and are not.

Do not nest a NavigationStack inside another one. It is the most common cause of double navigation bars and back buttons that go to the wrong place. One stack per tab, and screens inside it are plain views.

Do not put a TabView inside a NavigationStack. It is the wrong way round — the tabs are the outer structure and each one owns a stack, not the other way about. Getting this backwards produces an app where the navigation bar belongs to the wrong screen.

One more thing the router makes easy

Because navigation is state in an object rather than a side effect in a view, a few things that are normally awkward become ordinary. Popping to root and switching tab is two calls from anywhere — it is what the "Back to the menu" button on the receipt screen does. Presenting sign-in from three different places needs no shared parent, because nothing is being passed down. And a test can drive the router directly and assert on the resulting path without rendering a single view.

The cost is one more type to hold in your head, and a rule to keep: features ask the router to navigate; they do not read its state to decide what to draw. Once a screen starts branching on where it is in the stack, the router has become a second, worse source of truth.

Next

Navigation usually starts from a list, and lists are where SwiftUI performance is won or lost. The next lesson is List against LazyVStack against LazyVGrid, why identity matters more than it looks, and what the framework does instead of React's memo.