SwiftUI Navigation at Scale: NavigationPath, Router, TabView and Deep Links

Advanced iOS Development: Concurrency, Architecture, Security and Release · Advanced UI and Navigation · 7 min read · Updated

Open a screen from a link

  1. A link arrives from a notification, a web page or another app.
  2. SwiftUI passes it to onOpenURL.
  3. The router checks the scheme, host and id, then clears the stack and appends a route.
  4. The navigation stack shows the screen for that route.

In the beginner course, a tap on a row pushed a screen. Real apps need more. A notification should open a particular expense. A button deep in a flow should jump back to the start. A link on a web page should open the app on the right screen. For all of these, navigation must be something your code can control, and not only the user's taps. This lesson shows how.

NavigationStack(path:) takes a binding to the list of screens currently on the stack. If you change the list, the screens change. Push by appending, pop by removing, go back to the start by emptying it. NavigationPath holds values of mixed types, and an array works when all screens use one type.

import SwiftUI

enum Route: Hashable {
    case expense(UUID)
    case settings
    case category(String)
}

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

    var body: some View {
        NavigationStack(path: $path) {
            List {
                Button("Open settings") { path.append(Route.settings) }
                Button("Open food") { path.append(Route.category("Food")) }
            }
            .navigationTitle("Home")
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .expense(let id): Text("Expense \(id)")
                case .settings: Text("Settings")
                case .category(let name): Text("Category \(name)")
                }
            }
        }
    }
}

Using one Route enum for the whole stack is a tidy habit. All the places you can go are listed in one type, and the switch makes sure every case has a screen.

A small Router

When many views need to navigate, hand them one shared object. With the @Observable macro from iOS 17, the object is a plain class, and any view that reads path redraws when it changes.

import SwiftUI
import Observation

@MainActor
@Observable
final class Router {
    var path = NavigationPath()

    func push(_ route: Route) { path.append(route) }
    func pop() { if !path.isEmpty { path.removeLast() } }
    func popToRoot() { path = NavigationPath() }

    func handle(_ url: URL) {
        guard url.scheme == "expensetracker", url.host == "expense",
              let id = UUID(uuidString: url.lastPathComponent) else { return }
        popToRoot()
        push(.expense(id))
    }
}

struct AppRoot: View {
    @State private var router = Router()

    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .expense(let id): ExpenseScreen(id: id)
                    case .settings: SettingsScreen()
                    case .category(let name): CategoryScreen(name: name)
                    }
                }
        }
        .environment(router)
        .onOpenURL { url in router.handle(url) }
    }
}

// in any child view
struct HomeView: View {
    @Environment(Router.self) private var router
    var body: some View {
        Button("Settings") { router.push(.settings) }
    }
}
  • .environment(router) puts the router where every child can find it with @Environment(Router.self).
  • handle(_:) reads a link such as expensetracker://expense/<id>, empties the stack, then pushes the target. Starting from a clean stack avoids odd back buttons.
  • This is the idea behind the Coordinator pattern from UIKit, in a smaller form. One object decides where the app goes, and screens only ask.

TabView

A TabView gives an app its tab bar. Each tab usually has its own NavigationStack with its own history, so switching tabs does not lose your place.

enum AppTab: Hashable { case expenses, charts, settings }

struct MainTabs: View {
    @State private var selection: AppTab = .expenses

    var body: some View {
        TabView(selection: $selection) {
            NavigationStack { Text("Expenses") }
                .tabItem { Label("Expenses", systemImage: "list.bullet") }
                .tag(AppTab.expenses)

            NavigationStack { Text("Charts") }
                .tabItem { Label("Charts", systemImage: "chart.bar") }
                .tag(AppTab.charts)

            NavigationStack { Text("Settings") }
                .tabItem { Label("Settings", systemImage: "gear") }
                .tag(AppTab.settings)
        }
    }
}

Because the selection is a value, a deep link can also switch tabs by setting selection. Newer iOS versions add a new Tab type for building tab bars. The tabItem form above works on all the versions this course targets.

  • Custom URL scheme such as expensetracker://expense/123. In Xcode, open your target, then Info > URL Types, and add the scheme. Easy to set up, but any app can claim the same scheme.
  • Universal Links such as https://example.com/expense/123. The web address opens your app if it is installed, and the website if not. You add the Associated Domains capability with applinks:example.com, and host an apple-app-site-association file on your website, over HTTPS, listing the paths your app handles. The details of the file are in Apple's documentation and change with iOS versions, so read the current page before you set it up.
  • Both arrive in SwiftUI through .onOpenURL, as above. Notification taps usually carry a URL or an id in their payload, which you hand to the same handle function.

Keeping place: state restoration

People expect to come back to where they were. @SceneStorage keeps small values, such as the selected tab, across launches of a scene, and NavigationPath has a codable form you can save and restore when all your route types are Codable. Use it for the main flows, and do not worry about restoring every sheet.

Common mistakes

  • A route enum that holds big objects. Put an id in the route and load the object in the destination.
  • Two NavigationStacks nested by accident, which gives two title bars.
  • Opening a link without checking that the data still exists. Show a clear *not found* message.

Your task

  1. Add a Route enum and a Router to your Expense Tracker.
  2. Register the URL scheme and test it in the Simulator with xcrun simctl openurl booted "expensetracker://expense/<an id from your data>".
  3. Put the app in a TabView with a separate stack in each tab.
  4. Add a *popToRoot* button on a detail screen and check that it works from three levels deep.

How to check it worked

  • Opening the link in the Simulator shows the right expense, even from another tab or screen.
  • Each tab keeps its own history.
  • You can say why an incoming link must be treated as untrusted input.

Frequently asked questions

What is NavigationPath in SwiftUI?

A list of the screens currently on a NavigationStack. Changing it from code pushes or pops screens, which lets you navigate from notifications, links and buttons deep in a flow.

What is the difference between a URL scheme and a Universal Link?

A URL scheme is a custom prefix such as myapp:// that any app may claim. A Universal Link is a normal https address that opens your app if installed and the website if not, and it needs a file on your domain and the Associated Domains capability.

Do I need the Coordinator pattern in SwiftUI?

Usually not in its UIKit form. A small Router with a NavigationPath gives the same benefit, one place that decides where the app goes.