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 NavigationSplitView — value-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.
| API | Era | Use for |
|---|---|---|
NavigationView | iOS 13–15 | Deprecated. Don’t write new code with it. |
NavigationLink(isActive:) | iOS 13–15 | Deprecated. Bug-prone. |
NavigationStack | iOS 16+ | Single-column push/pop navigation (iPhone, iPad portrait) |
NavigationSplitView | iOS 16+ | Multi-column sidebar/list/detail (iPad, macOS, large iPhones in landscape) |
navigationDestination(for:) | iOS 16+ | Map a value type to a destination view |
NavigationPath | iOS 16+ | Type-erased path for deep linking |
.sheet/.fullScreenCover | iOS 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)
NavigationStack — value-driven push/pop
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 aT- 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.
NavigationSplitView — multi-column
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
Identifiablevalue - Versus
sheet(isPresented:)(Boolean) — preferitem: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
NavigationSplitViewextensively; 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
Routeenum per tab and stores paths in their flow coordinator. - Apple Reminders uses
NavigationSplitViewwith custom column visibility per orientation.
Common misconceptions
- “
NavigationViewstill works, don’t bother migrating.” It’s deprecated and gets less attention each release. New APIs (.navigationDestination,NavigationPath) don’t work insideNavigationView. Migrate when you touch the file. - “
NavigationLinkis the only way to push.” Programmatic push (path.append(...)) is fully supported and necessary for deep linking, post-action navigation, and tests. - “
NavigationPathand[Route]are different.” They serve the same goal;[Route]gives you compile-time type safety,NavigationPathallows mixed types. Use the typed array unless you need heterogeneity. - “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
NavigationStackinside the sheet if you need internal navigation. - “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
RouteenumCodable(in addition toHashable). Then you can persistpathto 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. UseNavigationLink(_, 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...)checkslaunchOptions[.remoteNotification]; defer the navigation until SwiftUI hierarchy is up (.onAppearon root, or.taskwith 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