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.
NavigationPath: the stack as data
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 asexpensetracker://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.
Deep links
- 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 withapplinks:example.com, and host anapple-app-site-associationfile 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 samehandlefunction.
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
- Add a
Routeenum and aRouterto your Expense Tracker. - Register the URL scheme and test it in the Simulator with
xcrun simctl openurl booted "expensetracker://expense/<an id from your data>". - Put the app in a
TabViewwith a separate stack in each tab. - 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.