关于 axiom-swiftui-nav
面向 SwiftUI 开发者的导航技能,帮助选择和实现导航结构、深层链接与状态恢复。内容结合决策指导、实用模式,以及常见易错设计的警示。
主要功能
- 01选择堆栈、分栏或标签结构
- 02使用 NavigationPath 实现程序化路由
- 03设置深层链接和 URL 路由
- 04保存并恢复导航状态
- 05应用 Router 模式并规避反模式
适用场景
- 01为应用选择导航结构
- 02通过深层链接将用户导向指定页面
- 03在应用重启后保留导航状态
- 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+