Add scan flow MVP and local Axiom skill workspace
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.
This commit is contained in:
7
.claude/skills/axiom-swiftui-nav/.openskills.json
Normal file
7
.claude/skills/axiom-swiftui-nav/.openskills.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"source": "CharlesWiltgen/Axiom",
|
||||
"sourceType": "git",
|
||||
"repoUrl": "https://github.com/CharlesWiltgen/Axiom",
|
||||
"subpath": "axiom-codex/skills/axiom-swiftui-nav",
|
||||
"installedAt": "2026-04-12T08:06:49.541Z"
|
||||
}
|
||||
836
.claude/skills/axiom-swiftui-nav/SKILL.md
Normal file
836
.claude/skills/axiom-swiftui-nav/SKILL.md
Normal file
@@ -0,0 +1,836 @@
|
||||
---
|
||||
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+
|
||||
3
.claude/skills/axiom-swiftui-nav/agents/openai.yaml
Normal file
3
.claude/skills/axiom-swiftui-nav/agents/openai.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
interface:
|
||||
display_name: "SwiftUI Nav"
|
||||
short_description: "Implementing navigation patterns, choosing between NavigationStack and NavigationSplitView, handling deep links, adop..."
|
||||
Reference in New Issue
Block a user