SwiftUI Navigation
When to Use This Skill
Use when:
- Choosing navigation architecture (NavigationStack vs NavigationSplitView vs TabView)
- Implementing programmatic navigation with NavigationPath
- Setting up deep linking and URL routing
- Implementing state restoration for navigation
- Adopting Tab/Sidebar patterns (iOS 18+)
- Implementing coordinator/router patterns
- Requesting code review of navigation implementation before shipping
Related Skills
- Use
axiom-swiftui-nav-diag for systematic troubleshooting of navigation failures
- Use
axiom-swiftui-nav-ref for comprehensive API reference (including Tab customization, iOS 26+ features) with all WWDC examples
Example Prompts
These are real questions developers ask that this skill is designed to answer:
1. "Should I use NavigationStack or NavigationSplitView for my app?"
-> The skill provides a decision tree based on device targets, content hierarchy depth, and multiplatform requirements
2. "How do I navigate programmatically in SwiftUI?"
-> The skill shows NavigationPath manipulation patterns for push, pop, pop-to-root, and deep linking
3. "My deep links aren't working. The app opens but shows the wrong screen."
-> The skill covers URL parsing patterns, path construction order, and timing issues with onOpenURL
4. "Navigation state is lost when my app goes to background."
-> The skill demonstrates Codable NavigationPath, SceneStorage persistence, and crash-resistant restoration
5. "How do I implement a coordinator pattern in SwiftUI?"
-> The skill provides Router pattern examples alongside guidance on when coordinators add value vs complexity
Red Flags โ Anti-Patterns to Prevent
If you're doing ANY of these, STOP and use the patterns in this skill:
โ CRITICAL โ Never Do These
1. Using deprecated NavigationView on iOS 16+
NavigationView {
List { ... }
}
.navigationViewStyle(.stack)
Why this fails NavigationView is deprecated since iOS 16. It lacks NavigationPath support, making programmatic navigation and deep linking unreliable. Different behavior across iOS versions causes bugs.
2. Using view-based NavigationLink for programmatic navigation
NavigationLink("Recipe") {
RecipeDetail(recipe: recipe)
}
Why this fails View-based links cannot be controlled programmatically. No way to deep link or pop to this destination. Deprecated since iOS 16.
3. Putting navigationDestination inside lazy containers
LazyVGrid(columns: columns) {
ForEach(items) { item in
NavigationLink(value: item) { ... }
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item)
}
}
}
Why this fails Lazy containers don't load all views immediately. navigationDestination may not be visible to NavigationStack, causing navigation to silently fail.
4. Storing full model objects in NavigationPath for restoration
class NavigationModel: Codable {
var path: [Recipe] = []
}
Why this fails Duplicates data already in your model. On restore, Recipe data may be stale (edited/deleted elsewhere). Use IDs and resolve to current data.
5. Modifying NavigationPath outside MainActor
Task.detached {
await viewModel.path.append(recipe)
}
Why this fails NavigationPath binds to UI. Modifications must happen on MainActor or navigation state becomes corrupted. Can cause crashes or silent failures.
6. Missing @MainActor isolation for navigation state
class Router: ObservableObject {
@Published var path = NavigationPath()
}
Why this fails In Swift 6 strict concurrency, @Published properties accessed from SwiftUI views require MainActor isolation. Causes data race warnings and potential crashes.
7. Not handling navigation state in multi-tab apps
TabView {
Tab("Home") { HomeView() }
Tab("Settings") { SettingsView() }
}
Why this fails Each tab should have its own NavigationStack to preserve navigation state when switching tabs. Shared state causes confusing UX.
8. Ignoring NavigationPath decoding errors
let path = NavigationPath(try! decoder.decode(NavigationPath.CodableRepresentation.self, from: data))
Why this fails User may have deleted items that were in the path. Schema may have changed. Force unwrap causes crash on restore.
Mandatory First Steps
ALWAYS complete these steps before implementing navigation:
struct Recipe: Hashable, Codable, Identifiable { ... }
Quick Decision Tree
Need navigation?
โโ Multi-column interface (iPad/Mac primary)?
โ โโ NavigationSplitView
โ โโ Need drill-down in detail column?
โ โ โโ NavigationStack inside detail (Pattern 3)
โ โโ Selection-only detail?
โ โโ Just selection binding (Pattern 2)
โโ Tab-based app?
โ โโ TabView
โ โโ Each tab needs drill-down?
โ โ โโ NavigationStack per tab (Pattern 4)
โ โโ iPad sidebar experience?
โ โโ .tabViewStyle(.sidebarAdaptable) (Pattern 5)
โโ Single-column stack?
โโ NavigationStack
โโ Need deep linking?
โ โโ Use NavigationPath (Pattern 1b)
โโ Simple push/pop?
โโ Typed array path (Pattern 1a)
Need state restoration?
โโ SceneStorage + Codable NavigationPath (Pattern 6)
Need coordinator abstraction?
โโ Complex conditional flows?
โโ Navigation logic testing needed?
โโ Sharing navigation across many screens?
โโ YES to any โ Router pattern (Pattern 7)
NO to all โ Use NavigationPath directly
Pattern 1a: Basic NavigationStack
When: Simple push/pop navigation, all destinations same type
Time cost: 5-10 min
struct RecipeList: View {
@State private var path: [Recipe] = []
var body: some View {
NavigationStack(path: $path) {
List(recipes) { recipe in
NavigationLink(recipe.name, value: recipe)
}
.navigationTitle("Recipes")
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
}
}
func showRecipe(_ recipe: Recipe) {
path.append(recipe)
}
func popToRoot() {
path.removeAll()
}
}
Key points:
- Typed array
[Recipe] when all values are same type
- Value-based
NavigationLink(title, value:)
navigationDestination(for:) outside lazy containers
Pattern 1b: NavigationStack with Deep Linking
When: Multiple destination types, URL-based deep linking
Time cost: 15-20 min
struct ContentView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
HomeView()