This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
837 lines
24 KiB
Markdown
837 lines
24 KiB
Markdown
---
|
|
name: axiom-swiftui-nav
|
|
description: Use when implementing navigation patterns, choosing between NavigationStack and NavigationSplitView, handling deep links, adopting coordinator patterns, or requesting code review of navigation implementation - prevents navigation state corruption, deep link failures, and state restoration bugs for iOS 18+
|
|
license: MIT
|
|
compatibility: iOS 18+ (Tab/Sidebar), iOS 26+ (Liquid Glass)
|
|
metadata:
|
|
version: "1.0.0"
|
|
last-updated: "2025-12-05"
|
|
---
|
|
|
|
# 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+
|
|
```swift
|
|
// ❌ WRONG — Deprecated, different behavior 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
|
|
```swift
|
|
// ❌ WRONG — Cannot programmatically control
|
|
NavigationLink("Recipe") {
|
|
RecipeDetail(recipe: recipe) // View destination, no value
|
|
}
|
|
```
|
|
**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
|
|
```swift
|
|
// ❌ WRONG — May not be loaded when needed
|
|
LazyVGrid(columns: columns) {
|
|
ForEach(items) { item in
|
|
NavigationLink(value: item) { ... }
|
|
.navigationDestination(for: Item.self) { item in // Don't do this
|
|
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
|
|
```swift
|
|
// ❌ WRONG — Duplicates data, stale on restore
|
|
class NavigationModel: Codable {
|
|
var path: [Recipe] = [] // Full Recipe objects
|
|
}
|
|
```
|
|
**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
|
|
```swift
|
|
// ❌ WRONG — UI update off main thread
|
|
Task.detached {
|
|
await viewModel.path.append(recipe) // Background thread
|
|
}
|
|
```
|
|
**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
|
|
```swift
|
|
// ❌ WRONG — Not MainActor isolated
|
|
class Router: ObservableObject {
|
|
@Published var path = NavigationPath() // No @MainActor
|
|
}
|
|
```
|
|
**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
|
|
```swift
|
|
// ❌ WRONG — Shared NavigationPath across tabs
|
|
TabView {
|
|
Tab("Home") { HomeView() }
|
|
Tab("Settings") { SettingsView() }
|
|
}
|
|
// All tabs share same NavigationStack — wrong!
|
|
```
|
|
**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
|
|
```swift
|
|
// ❌ WRONG — Crashes on invalid data
|
|
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:
|
|
|
|
```swift
|
|
// Step 1: Identify your navigation structure
|
|
// Ask: Single stack? Multi-column? Tab-based with per-tab navigation?
|
|
// Record answer before writing any code
|
|
|
|
// Step 2: Choose container based on structure
|
|
// Single stack (iPhone-primary): NavigationStack
|
|
// Multi-column (iPad/Mac-primary): NavigationSplitView
|
|
// Tab-based: TabView with NavigationStack per tab
|
|
|
|
// Step 3: Define your value types for navigation
|
|
// All values pushed on NavigationStack must be Hashable
|
|
// For deep linking/restoration, also Codable
|
|
struct Recipe: Hashable, Codable, Identifiable { ... }
|
|
|
|
// Step 4: Plan deep link URLs (if needed)
|
|
// myapp://recipe/{id}
|
|
// myapp://category/{name}/recipe/{id}
|
|
|
|
// Step 5: Plan state restoration (if needed)
|
|
// Will you use SceneStorage? What data must be Codable?
|
|
```
|
|
|
|
---
|
|
|
|
## 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
|
|
|
|
```swift
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Programmatic navigation
|
|
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
|
|
|
|
```swift
|
|
struct ContentView: View {
|
|
@State private var path = NavigationPath()
|
|
|
|
var body: some View {
|
|
NavigationStack(path: $path) {
|
|
HomeView()
|
|
.navigationDestination(for: Category.self) { category in
|
|
CategoryView(category: category)
|
|
}
|
|
.navigationDestination(for: Recipe.self) { recipe in
|
|
RecipeDetail(recipe: recipe)
|
|
}
|
|
}
|
|
.onOpenURL { url in
|
|
handleDeepLink(url)
|
|
}
|
|
}
|
|
|
|
func handleDeepLink(_ url: URL) {
|
|
// URL: myapp://category/desserts/recipe/apple-pie
|
|
path.removeLast(path.count) // Pop to root first
|
|
|
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
|
|
let segments = components.path.split(separator: "/").map(String.init)
|
|
|
|
var index = 0
|
|
while index < segments.count - 1 {
|
|
switch segments[index] {
|
|
case "category":
|
|
if let category = Category(rawValue: segments[index + 1]) {
|
|
path.append(category)
|
|
}
|
|
index += 2
|
|
case "recipe":
|
|
if let recipe = dataModel.recipe(named: segments[index + 1]) {
|
|
path.append(recipe)
|
|
}
|
|
index += 2
|
|
default:
|
|
index += 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key points:**
|
|
- `NavigationPath` for heterogeneous types
|
|
- Pop to root before building deep link path
|
|
- Build path in correct order (parent → child)
|
|
|
|
---
|
|
|
|
## Pattern 2: NavigationSplitView Selection-Based
|
|
|
|
**When**: Multi-column layout where detail shows selected item
|
|
|
|
**Time cost**: 10-15 min
|
|
|
|
```swift
|
|
struct MultiColumnView: View {
|
|
@State private var selectedCategory: Category?
|
|
@State private var selectedRecipe: Recipe?
|
|
|
|
var body: some View {
|
|
NavigationSplitView {
|
|
List(Category.allCases, selection: $selectedCategory) { category in
|
|
NavigationLink(category.name, value: category)
|
|
}
|
|
.navigationTitle("Categories")
|
|
} content: {
|
|
if let category = selectedCategory {
|
|
List(recipes(in: category), selection: $selectedRecipe) { recipe in
|
|
NavigationLink(recipe.name, value: recipe)
|
|
}
|
|
.navigationTitle(category.name)
|
|
} else {
|
|
Text("Select a category")
|
|
}
|
|
} detail: {
|
|
if let recipe = selectedRecipe {
|
|
RecipeDetail(recipe: recipe)
|
|
} else {
|
|
Text("Select a recipe")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key points:**
|
|
- `selection: $binding` on List connects to column selection
|
|
- Value-presenting links update selection automatically
|
|
- Adapts to single stack on iPhone
|
|
|
|
---
|
|
|
|
## Pattern 3: NavigationSplitView with Stack in Detail
|
|
|
|
**When**: Multi-column with drill-down capability in detail
|
|
|
|
**Time cost**: 20-25 min
|
|
|
|
```swift
|
|
struct GridWithDrillDown: View {
|
|
@State private var selectedCategory: Category?
|
|
@State private var path: [Recipe] = []
|
|
|
|
var body: some View {
|
|
NavigationSplitView {
|
|
List(Category.allCases, selection: $selectedCategory) { category in
|
|
NavigationLink(category.name, value: category)
|
|
}
|
|
.navigationTitle("Categories")
|
|
} detail: {
|
|
NavigationStack(path: $path) {
|
|
if let category = selectedCategory {
|
|
RecipeGrid(category: category)
|
|
.navigationDestination(for: Recipe.self) { recipe in
|
|
RecipeDetail(recipe: recipe)
|
|
}
|
|
} else {
|
|
Text("Select a category")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key points:**
|
|
- NavigationStack inside detail column
|
|
- Grid → Detail drill-down while preserving sidebar
|
|
- Separate path for drill-down, selection for sidebar
|
|
|
|
---
|
|
|
|
## Pattern 4: TabView with Per-Tab NavigationStack
|
|
|
|
**When**: Tab-based app where each tab has its own navigation
|
|
|
|
**Time cost**: 15-20 min
|
|
|
|
```swift
|
|
struct TabBasedApp: View {
|
|
var body: some View {
|
|
TabView {
|
|
Tab("Home", systemImage: "house") {
|
|
NavigationStack {
|
|
HomeView()
|
|
.navigationDestination(for: Item.self) { item in
|
|
ItemDetail(item: item)
|
|
}
|
|
}
|
|
}
|
|
|
|
Tab("Search", systemImage: "magnifyingglass") {
|
|
NavigationStack {
|
|
SearchView()
|
|
}
|
|
}
|
|
|
|
Tab("Settings", systemImage: "gear") {
|
|
NavigationStack {
|
|
SettingsView()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key points:**
|
|
- Each Tab has its own NavigationStack
|
|
- Navigation state preserved when switching tabs
|
|
- iOS 18+ Tab syntax with systemImage
|
|
|
|
---
|
|
|
|
## Pattern 5: Sidebar-Adaptable TabView (iOS 18+)
|
|
|
|
**When**: Tab bar on iPhone, sidebar on iPad
|
|
|
|
**Time cost**: 20-25 min
|
|
|
|
```swift
|
|
struct AdaptableApp: View {
|
|
var body: some View {
|
|
TabView {
|
|
Tab("Watch Now", systemImage: "play") {
|
|
WatchNowView()
|
|
}
|
|
Tab("Library", systemImage: "books.vertical") {
|
|
LibraryView()
|
|
}
|
|
|
|
TabSection("Collections") {
|
|
Tab("Favorites", systemImage: "star") {
|
|
FavoritesView()
|
|
}
|
|
Tab("Recently Added", systemImage: "clock") {
|
|
RecentView()
|
|
}
|
|
}
|
|
|
|
Tab(role: .search) {
|
|
SearchView()
|
|
}
|
|
}
|
|
.tabViewStyle(.sidebarAdaptable)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key points:**
|
|
- `.tabViewStyle(.sidebarAdaptable)` enables sidebar on iPad
|
|
- `TabSection` creates collapsible groups in sidebar
|
|
- `Tab(role: .search)` gets special placement
|
|
|
|
---
|
|
|
|
## Pattern 6: State Restoration
|
|
|
|
**When**: Preserve navigation state across app launches
|
|
|
|
**Time cost**: 25-30 min
|
|
|
|
```swift
|
|
@MainActor
|
|
class NavigationModel: ObservableObject, Codable {
|
|
@Published var selectedCategory: Category?
|
|
@Published var recipePath: [Recipe.ID] = [] // Store IDs, not objects
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case selectedCategory, recipePath
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory)
|
|
try container.encode(recipePath, forKey: .recipePath)
|
|
}
|
|
|
|
required init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
selectedCategory = try container.decodeIfPresent(Category.self, forKey: .selectedCategory)
|
|
recipePath = try container.decode([Recipe.ID].self, forKey: .recipePath)
|
|
}
|
|
|
|
init() {}
|
|
|
|
var jsonData: Data? {
|
|
get { try? JSONEncoder().encode(self) }
|
|
set {
|
|
guard let data = newValue,
|
|
let model = try? JSONDecoder().decode(NavigationModel.self, from: data)
|
|
else { return }
|
|
selectedCategory = model.selectedCategory
|
|
recipePath = model.recipePath
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ContentView: View {
|
|
@StateObject private var navModel = NavigationModel()
|
|
@SceneStorage("navigation") private var data: Data?
|
|
|
|
var body: some View {
|
|
NavigationStack(path: $navModel.recipePath) {
|
|
// Content
|
|
}
|
|
.task {
|
|
if let data { navModel.jsonData = data }
|
|
for await _ in navModel.objectWillChange.values {
|
|
data = navModel.jsonData
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key points:**
|
|
- Store IDs, resolve to current objects
|
|
- `@MainActor` for Swift 6 concurrency safety
|
|
- SceneStorage for automatic scene-scoped persistence
|
|
- Use `compactMap` when resolving IDs to handle deleted items
|
|
|
|
---
|
|
|
|
## Pattern 7: Router/Coordinator
|
|
|
|
**When**: Complex navigation logic, need testability
|
|
|
|
**Time cost**: 30-45 min
|
|
|
|
```swift
|
|
enum AppRoute: Hashable {
|
|
case home
|
|
case category(Category)
|
|
case recipe(Recipe)
|
|
case settings
|
|
}
|
|
|
|
@Observable
|
|
@MainActor
|
|
class Router {
|
|
var path = NavigationPath()
|
|
|
|
func navigate(to route: AppRoute) {
|
|
path.append(route)
|
|
}
|
|
|
|
func pop() {
|
|
guard !path.isEmpty else { return }
|
|
path.removeLast()
|
|
}
|
|
|
|
func popToRoot() {
|
|
path.removeLast(path.count)
|
|
}
|
|
|
|
func showRecipeOfTheDay() {
|
|
popToRoot()
|
|
if let recipe = DataModel.shared.recipeOfTheDay {
|
|
path.append(AppRoute.recipe(recipe))
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ContentView: View {
|
|
@State private var router = Router()
|
|
|
|
var body: some View {
|
|
NavigationStack(path: $router.path) {
|
|
HomeView()
|
|
.navigationDestination(for: AppRoute.self) { route in
|
|
switch route {
|
|
case .home: HomeView()
|
|
case .category(let cat): CategoryView(category: cat)
|
|
case .recipe(let recipe): RecipeDetail(recipe: recipe)
|
|
case .settings: SettingsView()
|
|
}
|
|
}
|
|
}
|
|
.environment(router)
|
|
}
|
|
}
|
|
```
|
|
|
|
**When coordinators add value:**
|
|
- Complex conditional navigation flows
|
|
- Navigation logic needs unit testing
|
|
- Multiple views trigger same navigation
|
|
- UIKit interop with custom transitions
|
|
|
|
**When coordinators add complexity without value:**
|
|
- Simple linear navigation
|
|
- < 5 navigation destinations
|
|
- No need for navigation testing
|
|
- NavigationPath already handles your deep linking
|
|
|
|
---
|
|
|
|
## Anti-Patterns (DO NOT DO THIS)
|
|
|
|
### ❌ Nesting NavigationStack inside NavigationStack
|
|
|
|
```swift
|
|
// ❌ WRONG — Nested stacks
|
|
NavigationStack {
|
|
SomeView()
|
|
.sheet(isPresented: $showSheet) {
|
|
NavigationStack { // Creates separate stack — confusing
|
|
SheetContent()
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Issue** Two navigation stacks create confusing UX. Back button behavior unclear.
|
|
**Fix** Use single NavigationStack, present sheets without nested navigation when possible.
|
|
|
|
### ❌ Using NavigationLink inside Button
|
|
|
|
```swift
|
|
// ❌ WRONG — Double navigation triggers
|
|
Button("Go") {
|
|
// Some action
|
|
} label: {
|
|
NavigationLink(value: item) { // Fires on button AND link
|
|
Text("Item")
|
|
}
|
|
}
|
|
```
|
|
|
|
**Issue** Both Button and NavigationLink respond to taps.
|
|
**Fix** Use only NavigationLink, put action in `.simultaneousGesture` if needed.
|
|
|
|
### ❌ Creating NavigationPath in view body
|
|
|
|
```swift
|
|
// ❌ WRONG — Recreated every render
|
|
var body: some View {
|
|
let path = NavigationPath() // Reset on every render!
|
|
NavigationStack(path: .constant(path)) { ... }
|
|
}
|
|
```
|
|
|
|
**Issue** Path recreated each render, navigation state lost.
|
|
**Fix** Use `@State` or `@StateObject` for navigation state.
|
|
|
|
---
|
|
|
|
## Pressure Scenario: "Make Navigation Like Instagram"
|
|
|
|
### The Problem
|
|
|
|
Product/design asks for complex navigation like Instagram:
|
|
- "Tab bar with per-tab navigation stacks"
|
|
- "Smooth coordinator pattern for all flows"
|
|
- "Deep linking to any screen"
|
|
- "Profile accessible from anywhere"
|
|
|
|
### Red Flags — Recognize Over-Engineering Pressure
|
|
|
|
If you hear ANY of these, **STOP and evaluate**:
|
|
|
|
- 🚩 **"Let's build a full coordinator layer before any views"** → Usually YAGNI
|
|
- 🚩 **"We need a navigation architecture that handles anything"** → Scope creep
|
|
- 🚩 **"Instagram/TikTok does it this way"** → They have 100+ engineers
|
|
|
|
### Time Cost Comparison
|
|
|
|
#### Option A: Over-Engineered Coordinator
|
|
- Time to build coordinator layer: 3-5 days
|
|
- Time to maintain and debug: Ongoing
|
|
- Time when requirements change: Significant refactor
|
|
|
|
#### Option B: Built-in Navigation + Simple Router
|
|
- Time to implement Pattern 4 (TabView + NavigationStack): 2-3 hours
|
|
- Time to add Router if needed: 1-2 hours
|
|
- Time when requirements change: Incremental additions
|
|
|
|
### How to Push Back Professionally
|
|
|
|
#### Step 1: Quantify Current Needs
|
|
```
|
|
"Let's list our actual navigation flows:
|
|
1. Home → Item Detail
|
|
2. Search → Results → Item Detail
|
|
3. Profile → Settings
|
|
|
|
That's 6 destinations. NavigationPath handles this natively."
|
|
```
|
|
|
|
#### Step 2: Show the Built-in Solution
|
|
```
|
|
"Here's our navigation with NavigationStack + NavigationPath:
|
|
[Show Pattern 1b code]
|
|
|
|
This gives us:
|
|
- Programmatic navigation ✓
|
|
- Deep linking ✓
|
|
- State restoration ✓
|
|
- Type safety ✓
|
|
|
|
Without a coordinator layer."
|
|
```
|
|
|
|
#### Step 3: Offer Incremental Path
|
|
```
|
|
"If we find NavigationPath insufficient, we can add a Router
|
|
(Pattern 7) later. It's 30-45 minutes of work.
|
|
|
|
But let's start with the simpler solution and add complexity
|
|
only when we hit a real limitation."
|
|
```
|
|
|
|
### Real-World Example: 48-Hour Feature Push
|
|
|
|
**Scenario:**
|
|
- PM: "We need deep linking for the campaign launch in 2 days"
|
|
- Lead: "Let's build a proper coordinator first"
|
|
- Time available: 16 working hours
|
|
|
|
**Wrong approach:**
|
|
- 8 hours: Build coordinator infrastructure
|
|
- 4 hours: Debug coordinator edge cases
|
|
- 4 hours: Rush deep linking on broken foundation
|
|
- Result: Buggy, deadline missed
|
|
|
|
**Correct approach:**
|
|
- 2 hours: Implement Pattern 1b (NavigationStack with deep linking)
|
|
- 1 hour: Test all deep link URLs
|
|
- 1 hour: Add SceneStorage restoration (Pattern 6)
|
|
- Result: Working deep links in 4 hours, 12 hours for polish/testing
|
|
|
|
---
|
|
|
|
## Pressure Scenario: "NavigationView Backward Compatibility"
|
|
|
|
### The Problem
|
|
|
|
Team lead says: "Let's use NavigationView so we support iOS 15"
|
|
|
|
### Red Flags
|
|
|
|
- 🚩 NavigationView deprecated since iOS 16 (2022)
|
|
- 🚩 Different behavior across iOS versions causes bugs
|
|
- 🚩 No NavigationPath support — can't deep link properly
|
|
|
|
### Data to Share
|
|
|
|
```
|
|
iOS 16+ adoption: 95%+ of active devices (as of 2024)
|
|
iOS 15: < 5% and declining
|
|
|
|
NavigationView limitations:
|
|
- No programmatic path manipulation
|
|
- No type-safe navigation
|
|
- No built-in state restoration
|
|
- Behavior varies by iOS version
|
|
```
|
|
|
|
### Push-Back Script
|
|
|
|
```
|
|
"NavigationView was deprecated in iOS 16 (2022). Here's the impact:
|
|
|
|
1. We lose NavigationPath — can't implement deep linking reliably
|
|
2. Behavior differs between iOS 15 and 16 — more bugs to maintain
|
|
3. iOS 15 is < 5% of users — we're adding complexity for small audience
|
|
|
|
Recommendation: Set deployment target to iOS 16, use NavigationStack.
|
|
If iOS 15 support is required, use NavigationStack with @available
|
|
checks and fallback UI for older devices."
|
|
```
|
|
|
|
---
|
|
|
|
## Code Review Checklist
|
|
|
|
### Navigation Architecture
|
|
- [ ] Correct container for use case (Stack vs SplitView vs TabView)
|
|
- [ ] Value-based NavigationLink (not view-based)
|
|
- [ ] navigationDestination outside lazy containers
|
|
- [ ] Each tab has own NavigationStack (if tab-based)
|
|
|
|
### State Management
|
|
- [ ] NavigationPath in @State or @StateObject (not recreated in body)
|
|
- [ ] @MainActor isolation for navigation state (Swift 6)
|
|
- [ ] IDs stored for restoration (not full objects)
|
|
- [ ] Error handling for decode failures
|
|
|
|
### Deep Linking
|
|
- [ ] onOpenURL handler present
|
|
- [ ] Pop to root before building path
|
|
- [ ] Path built in correct order (parent → child)
|
|
- [ ] Missing data handled gracefully
|
|
|
|
### iOS 26+ Features
|
|
- [ ] No custom backgrounds interfering with Liquid Glass
|
|
- [ ] Bottom-aligned search working on iPhone
|
|
- [ ] Tab bar minimization if appropriate
|
|
|
|
---
|
|
|
|
## Troubleshooting Quick Reference
|
|
|
|
| Symptom | Likely Cause | Pattern |
|
|
|---------|--------------|---------|
|
|
| Navigation doesn't respond to taps | NavigationLink outside NavigationStack | Check hierarchy |
|
|
| Double navigation on tap | Button wrapping NavigationLink | Remove Button wrapper |
|
|
| State lost on tab switch | Shared NavigationStack across tabs | Pattern 4 |
|
|
| State lost on background | No SceneStorage | Pattern 6 |
|
|
| Deep link shows wrong screen | Path built in wrong order | Pattern 1b |
|
|
| Crash on restore | Force unwrap decode | Handle errors gracefully |
|
|
|
|
---
|
|
|
|
## Resources
|
|
|
|
**WWDC**: 2022-10054, 2024-10147, 2025-256, 2025-323
|
|
|
|
**Skills**: axiom-swiftui-nav-diag, axiom-swiftui-nav-ref
|
|
|
|
---
|
|
|
|
**Last Updated** Based on WWDC 2022-2025 navigation sessions
|
|
**Platforms** iOS 18+, iPadOS 18+, macOS 15+, watchOS 11+, tvOS 18+
|