mcpField
A
axiom-swiftui-nav

axiom-swiftui-nav

github.com/charleswiltgen开发1.2k183 次安装今天更新
代码仓库

关于 axiom-swiftui-nav

面向 SwiftUI 开发者的导航技能,帮助选择和实现导航结构、深层链接与状态恢复。内容结合决策指导、实用模式,以及常见易错设计的警示。

主要功能

  1. 01选择堆栈、分栏或标签结构
  2. 02使用 NavigationPath 实现程序化路由
  3. 03设置深层链接和 URL 路由
  4. 04保存并恢复导航状态
  5. 05应用 Router 模式并规避反模式

适用场景

  1. 01为应用选择导航结构
  2. 02通过深层链接将用户导向指定页面
  3. 03在应用重启后保留导航状态
  4. 04发布前审查导航代码

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swiftui-nav

安装后在 Claude Code 中输入/axiom-swiftui-nav确认已加载。

详细介绍

SwiftUI 导航

何时使用此技能

适用于:

  • 选择导航架构(NavigationStack、NavigationSplitView 还是 TabView)
  • 使用 NavigationPath 实现程序化导航
  • 设置深层链接和 URL 路由
  • 实现导航状态恢复
  • 采用 Tab/侧边栏模式(iOS 18+)
  • 实现协调器/路由器模式
  • 发布前请求导航实现的代码审查
相关技能
  • 使用 axiom-swiftui-nav-diag 系统地排查导航故障
  • 使用 axiom-swiftui-nav-ref 查阅完整的 API 参考(包括 Tab 自定义、iOS 26+ 功能)和所有 WWDC 示例

示例提示词

以下是开发者提出的真实问题,本技能旨在解答这些问题:

1. “我的应用应该使用 NavigationStack 还是 NavigationSplitView?”

-> 本技能根据设备目标、内容层级深度和多平台需求提供决策树

2. “如何在 SwiftUI 中进行程序化导航?”

-> 本技能展示使用 NavigationPath 执行 push、pop、返回根页面和深层链接的操作模式

3. “我的深层链接不起作用。应用打开了,却显示了错误的页面。”

-> 本技能介绍 URL 解析模式、路径构建顺序以及 onOpenURL 的时序问题

4. “应用进入后台后,导航状态就丢失了。”

-> 本技能演示 Codable NavigationPath、SceneStorage 持久化和抗崩溃恢复

5. “如何在 SwiftUI 中实现协调器模式?”

-> 本技能提供 Router 模式示例,并指导你判断协调器何时能带来价值、何时只会增加复杂度


危险信号 — 应避免的反模式

如果你正在做以下任意一项,请立即停止,并使用本技能中的模式:

❌ 严重 — 切勿这样做
1. 在 iOS 16+ 中使用已弃用的 NavigationView
// ❌ WRONG — Deprecated, different behavior on iOS 16+
NavigationView {
    List { ... }
}
.navigationViewStyle(.stack)

为何会失败 NavigationView 自 iOS 16 起已弃用。它不支持 NavigationPath,导致程序化导航和深层链接不可靠。不同 iOS 版本上的行为各异,会引发错误。

2. 使用基于视图的 NavigationLink 进行程序化导航
// ❌ WRONG — Cannot programmatically control
NavigationLink("Recipe") {
    RecipeDetail(recipe: recipe)  // View destination, no value
}

为何会失败 基于视图的链接无法通过程序控制。无法深层链接到此目标页面,也无法返回此页面。自 iOS 16 起已弃用。

3. 将 navigationDestination 放在惰性容器内
// ❌ 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)
            }
    }
}

为何会失败 惰性容器不会立即加载所有视图。NavigationStack 可能无法看到 navigationDestination,导致导航静默失败。

4. 在 NavigationPath 中存储完整模型对象以恢复状态
// ❌ WRONG — Duplicates data, stale on restore
class NavigationModel: Codable {
    var path: [Recipe] = []  // Full Recipe objects
}

为何会失败 这会重复存储模型中已有的数据。恢复时,Recipe 数据可能已过期(在其他位置被编辑或删除)。请使用 ID,并解析为当前数据。

5. 在 MainActor 之外修改 NavigationPath
// ❌ WRONG — UI update off main thread
Task.detached {
    await viewModel.path.append(recipe)  // Background thread
}

为何会失败 NavigationPath 与 UI 绑定。必须在 MainActor 上进行修改,否则导航状态可能损坏。这可能导致崩溃或静默失败。

6. 未对导航状态进行 @MainActor 隔离
// ❌ WRONG — Not MainActor isolated
class Router: ObservableObject {
    @Published var path = NavigationPath()  // No @MainActor
}

为何会失败 在 Swift 6 严格并发模式下,SwiftUI 视图访问的 @Published 属性需要进行 MainActor 隔离。这会导致数据竞争警告,并可能引发崩溃。

7. 未处理多 Tab 应用中的导航状态
// ❌ WRONG — Shared NavigationPath across tabs
TabView {
    Tab("Home") { HomeView() }
    Tab("Settings") { SettingsView() }
}
// All tabs share same NavigationStack — wrong!

为何会失败 每个 Tab 都应有自己的 NavigationStack,以便在切换 Tab 时保留导航状态。共享状态会造成令人困惑的用户体验。

8. 忽略 NavigationPath 解码错误
// ❌ WRONG — Crashes on invalid data
let path = NavigationPath(try! decoder.decode(NavigationPath.CodableRepresentation.self, from: data))

为何会失败 用户可能已删除路径中包含的项目,也可能是架构已发生变化。强制解包会导致恢复时崩溃。


必须先完成的步骤

实现导航前,请务必完成以下步骤:

// 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?

快速决策树

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

模式 1a:基础 NavigationStack

适用场景:简单的 push/pop 导航,所有目标类型相同

时间成本:5-10 分钟

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()
    }
}

要点:

  • 当所有值类型相同时使用类型化数组 [Recipe]
  • 基于值的 NavigationLink(title, value:)
  • navigationDestination(for:) 放在惰性容器之外

模式 1b:带深度链接的 NavigationStack

适用场景:多种目标类型,基于 URL 的深度链接

时间成本:15-20 分钟

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
            }
        }
    }
}

要点:

  • 使用 NavigationPath 处理异构类型
  • 在构建深度链接路径前先弹回根视图
  • 按正确顺序构建路径(父级 → 子级)

模式 2:基于选择的 NavigationSplitView

适用场景:详情列显示所选项的多列布局

时间成本:10-15 分钟

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")
            }
        }
    }
}

要点:

  • List 上的 selection: $binding 连接到列选择
  • 呈现值的链接会自动更新选择
  • 在 iPhone 上适配为单栈

模式 3:详情列中带 Stack 的 NavigationSplitView

适用场景:详情列具备下钻能力的多列布局

时间成本:20-25 分钟

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")
                }
            }
        }
    }
}

要点:

  • 在详情列内使用 NavigationStack
  • 网格 → 详情下钻,同时保留侧边栏
  • 下钻使用单独的路径,侧边栏使用选择

模式 4:每个标签页各自带 NavigationStack 的 TabView

适用场景:基于标签页的应用,每个标签页有各自的导航

时间成本:15-20 分钟

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()
                }
            }
        }
    }
}

要点:

  • 每个 Tab 有各自的 NavigationStack
  • 切换标签页时保留导航状态
  • 使用 systemImage 的 iOS 18+ Tab 语法

模式 5:可适配侧边栏的 TabView(iOS 18+)

适用场景:iPhone 上显示标签栏,iPad 上显示侧边栏

时间成本:20-25 分钟

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)
    }
}

要点:

  • .tabViewStyle(.sidebarAdaptable) 在 iPad 上启用侧边栏
  • TabSection 在侧边栏中创建可折叠分组
  • Tab(role: .search) 获得特殊摆放位置

模式 6:状态恢复

适用场景:在应用多次启动之间保留导航状态

时间成本:25-30 分钟

@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
            }
        }
    }
}

要点:

  • 存储 ID,解析为当前对象
  • 使用 @MainActor 保证 Swift 6 并发安全
  • 使用 SceneStorage 实现自动的场景级持久化
  • 解析 ID 时使用 compactMap 以处理已删除的项目

模式 7:Router/Coordinator

适用场景:复杂的导航逻辑,需要可测试性

时间成本:30-45 分钟

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)
    }
}

Coordinator 能带来价值的场景:

  • 复杂条件导航流程
  • 导航逻辑需要单元测试
  • 多个视图触发相同的导航
  • 与 UIKit 互操作并使用自定义转场

Coordinator 增加复杂度而无价值的场景:

  • 简单的线性导航
  • 导航目标 < 5 个
  • 无需导航测试
  • NavigationPath 已能处理你的深度链接

反模式(切勿这样做)

❌ 在 NavigationStack 内嵌套 NavigationStack
// ❌ WRONG — Nested stacks
NavigationStack {
    SomeView()
        .sheet(isPresented: $showSheet) {
            NavigationStack {  // Creates separate stack — confusing
                SheetContent()
            }
        }
}

问题 两个导航栈会造成令人困惑的 UX。返回按钮行为不明确。 修复 使用单个 NavigationStack,尽可能在不嵌套导航的情况下呈现 sheet。

❌ 在 Button 内使用 NavigationLink
// ❌ WRONG — Double navigation triggers
Button("Go") {
    // Some action
} label: {
    NavigationLink(value: item) {  // Fires on button AND link
        Text("Item")
    }
}

问题 Button 和 NavigationLink 都会响应点击。 修复 仅使用 NavigationLink,如有需要将操作放在 .simultaneousGesture 中。

❌ 在视图 body 中创建 NavigationPath
// ❌ WRONG — Recreated every render
var body: some View {
    let path = NavigationPath()  // Reset on every render!
    NavigationStack(path: .constant(path)) { ... }
}

问题 路径在每次渲染时被重新创建,导航状态丢失。 修复 使用 @State 或 @StateObject 保存导航状态。


压力场景:“让导航像 Instagram 一样”

问题

产品/设计提出像 Instagram 一样复杂的导航需求:

  • “标签栏,每个标签页有各自的导航栈”
  • “为所有流程提供流畅的 coordinator 模式”
  • “可深度链接到任何屏幕”
  • “个人资料可从任何地方访问”
危险信号 — 识别过度工程化的压力

如果你听到以下任意说法,请停下来评估:

  • 🚩 “我们先构建完整的协调器层,再开始做任何视图” → 通常属于 YAGNI
  • 🚩 “我们需要一个能处理任何情况的导航架构” → 范围蔓延
  • 🚩 “Instagram/TikTok 就是这么做的” → 他们有 100 多名工程师
时间成本对比
方案 A:过度工程化的协调器
  • 构建协调器层所需时间:3-5 天
  • 维护和调试所需时间:持续投入
  • 需求变更时所需时间:大幅重构
方案 B:内置导航 + 简易路由器
  • 实现模式 4(TabView + NavigationStack)所需时间:2-3 小时
  • 按需添加 Router 所需时间:1-2 小时
  • 需求变更时所需时间:增量添加
如何专业地提出异议
步骤 1:量化当前需求
"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."
步骤 2:展示内置方案
"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."
步骤 3:提供渐进式方案
"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."
真实案例:48 小时功能冲刺

场景:

  • PM:“我们需要在 2 天内为活动上线实现深度链接”
  • 负责人:“我们先构建一个完善的协调器”
  • 可用时间:16 个工作小时

错误做法:

  • 8 小时:构建协调器基础设施
  • 4 小时:调试协调器的边缘情况
  • 4 小时:在不稳固的基础上仓促实现深度链接
  • 结果:漏洞百出,错过截止日期

正确做法:

  • 2 小时:实现模式 1b(支持深度链接的 NavigationStack)
  • 1 小时:测试所有深度链接 URL
  • 1 小时:添加 SceneStorage 恢复功能(模式 6)
  • 结果:4 小时内实现可用的深度链接,剩余 12 小时用于打磨和测试

压力场景:“NavigationView 向后兼容”

问题

团队负责人说:“我们用 NavigationView,这样就能支持 iOS 15。”

危险信号
  • 🚩 NavigationView 自 iOS 16(2022)起已弃用
  • 🚩 不同 iOS 版本的行为不一致,会导致漏洞
  • 🚩 不支持 NavigationPath — 无法正确实现深度链接
可分享的数据
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
提出异议的话术
"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."

代码审查清单

导航架构
  • 根据使用场景选择正确的容器(Stack、SplitView 或 TabView)
  • 使用基于值的 NavigationLink(而非基于视图的)
  • 将 navigationDestination 放在惰性容器之外
  • 每个标签页都有自己的 NavigationStack(如果采用标签页导航)
状态管理
  • 将 NavigationPath 存储在 @State 或 @StateObject 中(不要在 body 中重新创建)
  • 对导航状态采用 @MainActor 隔离(Swift 6)
  • 为恢复功能存储 ID(而非完整对象)
  • 妥善处理解码失败
深度链接
  • 存在 onOpenURL 处理器
  • 在构建路径前返回根视图
  • 按正确顺序构建路径(父级 → 子级)
  • 妥善处理缺失的数据
iOS 26+ 功能
  • 没有自定义背景妨碍 Liquid Glass
  • 在 iPhone 上,底部对齐的搜索功能可正常使用
  • 视情况启用标签栏最小化

故障排查速查表

症状可能原因模式
点击后导航无响应NavigationLink 位于 NavigationStack 之外检查层级结构
点击后发生两次导航Button 包裹了 NavigationLink移除 Button 包装
切换标签页后状态丢失多个标签页共用同一个 NavigationStack模式 4
应用进入后台后状态丢失未使用 SceneStorage模式 6
深度链接显示错误的屏幕路径构建顺序错误模式 1b
恢复时崩溃强制解包解码结果妥善处理错误

资源

WWDC:2022-10054、2024-10147、2025-256、2025-323

技能:axiom-swiftui-nav-diag、axiom-swiftui-nav-ref


最后更新 基于 WWDC 2022-2025 导航专题 平台 iOS 18+、iPadOS 18+、macOS 15+、watchOS 11+、tvOS 18+

评论

支持 Markdown