axiom-swiftui-nav-diag by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swiftui-nav-diag核心原则 85% 的导航问题源于路径状态管理错误、视图标识问题或放置错误——而非 SwiftUI 框架本身的缺陷。
SwiftUI 的导航系统被数百万应用使用,并能可靠地处理复杂的导航模式。如果你的导航失败、无响应或行为异常,问题几乎总是出在你如何管理导航状态上,而不是框架本身。
本技能提供系统性诊断方法,可在几分钟内(而非数小时)识别根本原因。
如果你遇到以下任何情况,请怀疑是代码问题,而非框架故障:
navigationDestination 导致的崩溃关键区别 NavigationStack 的行为是确定性的。如果它不工作,说明你错误地修改了状态、存在视图标识问题,或者 navigationDestination 放置不当。
始终首先运行这些检查(在更改代码之前):
// 1. 添加 NavigationPath 日志记录
NavigationStack(path: $path) {
RootView()
.onChange(of: path.count) { oldCount, newCount in
print("📍 Path changed: \(oldCount) → \(newCount)")
// 如果此事件从未触发,说明链接没有修改路径
// 如果意外触发,说明有其他东西在修改路径
}
}
// 2. 检查 navigationDestination 是否可见
// 在目标闭包中放置临时打印语句
.navigationDestination(for: Recipe.self) { recipe in
let _ = print("🔗 Destination for Recipe: \(recipe.name)")
RecipeDetail(recipe: recipe)
}
// 如果此语句从未打印,说明目标闭包未被求值
// 3. 检查 NavigationLink 是否在 NavigationStack 内部
// 视觉检查:从 NavigationLink 向上追踪视图层次结构
// 必须首先遇到 NavigationStack,而不是其他容器
// 4. 检查路径状态位置
// @State 必须在稳定的视图中(不是每次渲染都重新创建)
// 必须是 @State、@StateObject 或 @Observable —— 不是局部变量
// 5. 在隔离环境中测试基本用例
// 创建最小化复现示例
NavigationStack {
NavigationLink("Test", value: "test")
.navigationDestination(for: String.self) { str in
Text("Pushed: \(str)")
}
}
// 如果这个能工作,问题出在你的特定设置中
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
| 观察结果 | 诊断 | 下一步 |
|---|---|---|
| 点击时 onChange 从未触发 | NavigationLink 不在 NavigationStack 层次结构中 | 模式 1a |
| onChange 触发但视图未推送 | navigationDestination 未找到/未加载 | 模式 1b |
| onChange 触发,视图推送,然后立即弹出 | 视图标识问题或路径修改 | 模式 2a |
| 路径意外更改(非点击导致) | 外部代码修改路径 | 模式 2b |
| 深度链接 path.append() 不导航 | 时序问题或错误线程 | 模式 3b |
| 切换标签页时状态丢失 | NavigationStack 在标签页间共享 | 模式 4a |
| 第一次工作,返回后失败 | 视图重新创建问题 | 模式 5a |
在更改任何代码之前,识别出以下情况之一:
使用此树在 2 分钟内找到正确的诊断模式:
Navigation problem?
├─ Navigation tap does nothing?
│ ├─ NavigationLink inside NavigationStack?
│ │ ├─ No → Pattern 1a (Link outside Stack)
│ │ └─ Yes → Check navigationDestination
│ │
│ ├─ navigationDestination registered?
│ │ ├─ Inside lazy container? → Pattern 1b (Lazy Loading)
│ │ ├─ Type mismatch? → Pattern 1c (Type Registration)
│ │ └─ Blocked by sheet/popover? → Pattern 1d (Modal Blocking)
│ │
│ └─ Using view-based link?
│ └─ → Pattern 1e (Deprecated API)
│
├─ Unexpected pop back?
│ ├─ Immediate pop after push?
│ │ ├─ View body recreating path? → Pattern 2a (Path Recreation)
│ │ ├─ @State in wrong view? → Pattern 2a (State Location)
│ │ └─ ForEach id changing? → Pattern 2c (Identity Change)
│ │
│ ├─ Pop when shouldn't?
│ │ ├─ External code calling removeLast? → Pattern 2b (Unexpected Modification)
│ │ ├─ Task cancelled? → Pattern 2b (Async Cancellation)
│ │ └─ MainActor issue? → Pattern 2d (Threading)
│ │
│ └─ Back button behavior wrong?
│ └─ → Pattern 2e (Stack Corruption)
│
├─ Deep link not working?
│ ├─ URL not received?
│ │ ├─ onOpenURL not called? → Check URL scheme in Info.plist
│ │ └─ Universal Links issue? → Check apple-app-site-association
│ │
│ ├─ URL received, path not updated?
│ │ ├─ path.append not on MainActor? → Pattern 3a (Threading)
│ │ ├─ Timing issue (app not ready)? → Pattern 3b (Initialization)
│ │ └─ NavigationStack not created yet? → Pattern 3b (Lifecycle)
│ │
│ └─ Path updated, wrong screen shown?
│ ├─ Wrong path order? → Pattern 3c (Path Construction)
│ ├─ Wrong type appended? → Pattern 3c (Type Mismatch)
│ └─ Item not found? → Pattern 3d (Data Resolution)
│
├─ State lost?
│ ├─ Lost on tab switch?
│ │ ├─ Shared NavigationStack? → Pattern 4a (Shared State)
│ │ └─ Tab recreation? → Pattern 4a (Tab Identity)
│ │
│ ├─ Lost on background/foreground?
│ │ ├─ No SceneStorage? → Pattern 4b (No Persistence)
│ │ └─ Decode failure? → Pattern 4c (Decode Error)
│ │
│ └─ Lost on rotation/size change?
│ └─ → Pattern 4d (Layout Recreation)
│
├─ NavigationSplitView issue?
│ ├─ Sidebar not visible on iPad?
│ │ ├─ columnVisibility not set? → Pattern 6a (Column Visibility)
│ │ └─ Compact size class? → Pattern 6a (Automatic Adaptation)
│ │
│ ├─ Detail shows blank on iPad?
│ │ ├─ No default detail view? → Pattern 6b (Missing Detail)
│ │ └─ Selection binding nil? → Pattern 6b (Selection State)
│ │
│ └─ Works on iPhone, broken on iPad?
│ └─ → Pattern 6c (Platform Adaptation)
│
└─ Crash?
├─ EXC_BAD_ACCESS in navigation code?
│ └─ → Pattern 5a (Memory Issue)
│
├─ Fatal error: type not registered?
│ └─ → Pattern 5b (Missing Destination)
│
└─ Decode failure on restore?
└─ → Pattern 5c (Restoration Crash)
在继续查看模式之前:
时间成本 5-10 分钟
// 检查视图层次结构 — NavigationLink 必须在 NavigationStack 内部
// ❌ 错误 — 链接在堆栈外部
struct ContentView: View {
var body: some View {
VStack {
NavigationLink("Go", value: "test") // 在堆栈外部!
NavigationStack {
Text("Root")
}
}
}
}
// 检查:为 NavigationStack 添加背景色
NavigationStack {
Color.red // 如果链接在红色上,说明它在内部
}
// ✅ 正确 — 链接在堆栈内部
struct ContentView: View {
var body: some View {
NavigationStack {
VStack {
NavigationLink("Go", value: "test") // 在堆栈内部
Text("Root")
}
.navigationDestination(for: String.self) { str in
Text("Pushed: \(str)")
}
}
}
}
时间成本 10-15 分钟
// ❌ 错误 — 目标在惰性容器内部(可能未加载)
ScrollView {
LazyVStack {
ForEach(items) { item in
NavigationLink(item.name, value: item)
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item) // 可能未被求值!
}
}
}
}
// ✅ 正确 — 目标在惰性容器外部
ScrollView {
LazyVStack {
ForEach(items) { item in
NavigationLink(item.name, value: item)
}
}
}
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item) // 始终可用
}
时间成本 10 分钟
// 检查:值类型必须与目标类型完全匹配
// 链接使用 Recipe
NavigationLink(recipe.name, value: recipe) // value 是 Recipe
// 目标注册为... Recipe.ID?
.navigationDestination(for: Recipe.ID.self) { id in // ❌ 错误类型!
RecipeDetail(id: id)
}
// 精确匹配类型
NavigationLink(recipe.name, value: recipe) // Recipe
.navigationDestination(for: Recipe.self) { recipe in // ✅ Recipe
RecipeDetail(recipe: recipe)
}
// 或者将链接改为使用 ID
NavigationLink(recipe.name, value: recipe.id) // Recipe.ID
.navigationDestination(for: Recipe.ID.self) { id in // ✅ Recipe.ID
RecipeDetail(id: id)
}
print(type(of: value))时间成本 15-20 分钟
// ❌ 错误 — 路径在视图 body 中创建(每次渲染都重置)
struct ContentView: View {
var body: some View {
let path = NavigationPath() // 每次都重新创建!
NavigationStack(path: .constant(path)) {
// ...
}
}
}
// ❌ 错误 — @State 在会被重新创建的子视图中
struct ParentView: View {
@State var showChild = true
var body: some View {
if showChild {
ChildView() // 当 showChild 切换时重新创建
}
}
}
struct ChildView: View {
@State var path = NavigationPath() // 当 ChildView 重新创建时丢失
// ...
}
// ✅ 正确 — @State 在稳定层级
struct ContentView: View {
@State private var path = NavigationPath() // 跨渲染持久化
var body: some View {
NavigationStack(path: $path) {
RootView()
}
}
}
// ✅ 正确 — 对 ObservableObject 使用 @StateObject
struct ContentView: View {
@StateObject private var navModel = NavigationModel()
var body: some View {
NavigationStack(path: $navModel.path) {
RootView()
}
}
}
时间成本 10-15 分钟
// ❌ 错误 — 从后台任务修改路径
func loadAndNavigate() async {
let recipe = await fetchRecipe()
path.append(recipe) // ⚠️ 不在 MainActor 上!
}
// 检查:搜索 @MainActor 上下文之外的 path.append、path.removeLast
// ✅ 正确 — 确保 MainActor
@MainActor
func loadAndNavigate() async {
let recipe = await fetchRecipe()
path.append(recipe) // ✅ MainActor 隔离
}
// 或者显式调度
func loadAndNavigate() async {
let recipe = await fetchRecipe()
await MainActor.run {
path.append(recipe)
}
}
// ✅ 最佳 — 对 @Observable 使用 @MainActor
@Observable
@MainActor
class Router {
var path = NavigationPath()
func navigate(to value: any Hashable) {
path.append(value)
}
}
时间成本 15-20 分钟
// ❌ 错误 — 可能在 NavigationStack 存在之前被调用
.onOpenURL { url in
handleDeepLink(url) // NavigationStack 可能尚未渲染
}
func handleDeepLink(_ url: URL) {
path.append(parsedValue) // 修改尚不存在的路径
}
// ✅ 正确 — 延迟深度链接处理
@State private var pendingDeepLink: URL?
@State private var isReady = false
var body: some View {
NavigationStack(path: $path) {
RootView()
.onAppear {
isReady = true
if let url = pendingDeepLink {
handleDeepLink(url)
pendingDeepLink = nil
}
}
}
.onOpenURL { url in
if isReady {
handleDeepLink(url)
} else {
pendingDeepLink = url // 排队等待稍后处理
}
}
}
时间成本 10-15 分钟
// ❌ 错误 — 顺序错误(子级在父级之前)
// URL: myapp://category/desserts/recipe/apple-pie
func handleDeepLink(_ url: URL) {
path.append(recipe) // 首先推送 Recipe
path.append(category) // 然后推送 Category — 错误顺序
}
// 用户看到 Category 屏幕,而不是 Recipe 屏幕
// ✅ 正确 — 父级在子级之前
func handleDeepLink(_ url: URL) {
path.removeLast(path.count) // 清除现有路径
// 构建层次结构:父级 → 子级
path.append(category) // 第一:Category
path.append(recipe) // 第二:Recipe(显示此屏幕)
}
// 对于复杂路径,先构建数组
var newPath: [any Hashable] = []
// 解析 URL 片段...
newPath.append(category)
newPath.append(subcategory)
newPath.append(item)
// 然后应用
path = NavigationPath(newPath)
时间成本 15-20 分钟
// ❌ 错误 — 单个 NavigationStack 包装 TabView
NavigationStack(path: $path) {
TabView {
Tab("Home") { HomeView() }
Tab("Settings") { SettingsView() }
}
}
// 所有标签页共享相同的导航 — 状态混合/丢失
// ❌ 错误 — 跨标签页使用相同的 @State
@State var path = NavigationPath() // 共享
TabView {
Tab("Home") {
NavigationStack(path: $path) { ... } // 使用共享路径
}
Tab("Settings") {
NavigationStack(path: $path) { ... } // 相同路径!
}
}
// ✅ 正确 — 每个标签页有自己的 NavigationStack
TabView {
Tab("Home", systemImage: "house") {
NavigationStack { // 自己的堆栈
HomeView()
.navigationDestination(for: HomeItem.self) { ... }
}
}
Tab("Settings", systemImage: "gear") {
NavigationStack { // 自己的堆栈
SettingsView()
.navigationDestination(for: SettingItem.self) { ... }
}
}
}
// 对于每个标签页的路径跟踪:
struct HomeTab: View {
@State private var path = NavigationPath() // 标签页特定
var body: some View {
NavigationStack(path: $path) {
HomeView()
}
}
}
时间成本 15-20 分钟
// ❌ 错误 — 没有持久化机制
@State private var path = NavigationPath()
// 应用终止时路径丢失
// ✅ 正确 — 使用 SceneStorage + Codable
struct ContentView: View {
@StateObject private var navModel = NavigationModel()
@SceneStorage("navigation") private var savedData: Data?
var body: some View {
NavigationStack(path: $navModel.path) {
RootView()
}
.task {
// 出现时恢复
if let data = savedData {
navModel.restore(from: data)
}
// 更改时保存
for await _ in navModel.objectWillChange.values {
savedData = navModel.encoded()
}
}
}
}
@MainActor
class NavigationModel: ObservableObject {
@Published var path = NavigationPath()
func encoded() -> Data? {
guard let codable = path.codable else { return nil }
return try? JSONEncoder().encode(codable)
}
func restore(from data: Data) {
guard let codable = try? JSONDecoder().decode(
NavigationPath.CodableRepresentation.self,
from: data
) else { return }
path = NavigationPath(codable)
}
}
时间成本 10-15 分钟
// 路径上推送的每个类型都需要一个目标
// 你推送 Recipe
path.append(recipe) // Recipe 类型
// 但只注册了 Category
.navigationDestination(for: Category.self) { ... }
// 没有 Recipe 的目标!
// 注册你可能推送的**所有**类型
NavigationStack(path: $path) {
RootView()
.navigationDestination(for: Category.self) { category in
CategoryView(category: category)
}
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
.navigationDestination(for: Chef.self) { chef in
ChefProfile(chef: chef)
}
}
// 或者使用枚举路由类型进行单一注册
enum AppRoute: Hashable {
case category(Category)
case recipe(Recipe)
case chef(Chef)
}
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .category(let cat): CategoryView(category: cat)
case .recipe(let recipe): RecipeDetail(recipe: recipe)
case .chef(let chef): ChefProfile(chef: chef)
}
}
时间成本 15-20 分钟
// ❌ 错误 — 强制解包解码
func restore(from data: Data) {
let codable = try! JSONDecoder().decode( // 💥 崩溃!
NavigationPath.CodableRepresentation.self,
from: data
)
path = NavigationPath(codable)
}
// 崩溃原因:
// - 保存的路径包含不再存在的类型
// - Codable 编码在版本之间发生了变化
// - 保存的项目已被删除
// ✅ 正确 — 优雅解码并回退
func restore(from data: Data) {
do {
let codable = try JSONDecoder().decode(
NavigationPath.CodableRepresentation.self,
from: data
)
path = NavigationPath(codable)
} catch {
print("Navigation restore failed: \(error)")
path = NavigationPath() // 重新开始
// 可选地清除错误的保存数据
}
}
// ✅ 更好 — 存储 ID,解析为对象
class NavigationModel: ObservableObject, Codable {
var selectedIds: [String] = [] // 存储 ID
func resolvedPath(dataModel: DataModel) -> NavigationPath {
var path = NavigationPath()
for id in selectedIds {
if let item = dataModel.item(withId: id) {
path.append(item)
}
// 缺失的项目被静默跳过
}
return path
}
}
你有 2 小时向 CTO 提供:
// 带有诊断日志记录的发布版本
#if DEBUG || DIAGNOSTIC
NavigationStack(path: $path) {
// ...
}
.onChange(of: path.count) { old, new in
Analytics.log("nav_path_change", ["old": old, "new": new])
}
#endif
// 检查分析数据:
// - path.count 意外变为 0 → 路径重新创建
// - path.count 增加但无推送 → 缺少目标
// - 根本没有路径更改 → 链接未触发
// 影响导航的 iOS 18 更改:
// 1. 更严格的 MainActor 强制
// 2. TabView 中视图标识的更改
// 3. 新的导航生命周期时序
// 最常见的 iOS 18 问题:
// 以前偶然工作的代码现在失败了
// 检查:是否有任何路径修改在异步上下文中而没有 @MainActor?
Task {
let result = await fetch()
path.append(result) // ⚠️ iOS 18 对此更严格
}
// 找到根本原因:NavigationPath 在异步上下文中修改
// iOS 17 比较宽松,iOS 18 正确地强制执行 MainActor
// ❌ 旧代码(在 iOS 17 上工作,在 iOS 18 上中断)
func loadAndNavigate() async {
let recipe = await fetchRecipe()
path.append(recipe) // 竞态条件
}
// ✅ 修复:显式 MainActor 隔离
@MainActor
func loadAndNavigate() async {
let recipe = await fetchRecipe()
path.append(recipe) // ✅ 安全
}
// 或者:注解整个类
@Observable
@MainActor
class Router {
var path = NavigationPath()
func navigate(to value: any Hashable) {
path.append(value)
}
}
// 1. 在 iOS 17 设备上测试 — 仍然工作
// 2. 在 iOS 18 设备上测试 — 现在工作
// 3. 测试所有导航路径
// 4. 提交加急审核
// 加急审核理由:
// "影响 20% 用户的 iOS 18 兼容性关键 bug 修复"
已识别根本原因:导航代码未正确隔离到主线程。
iOS 18 比 iOS 17 更严格地强制执行这一点。
修复:为导航代码添加 @MainActor 注解。
已在 iOS 17(无回归)和 iOS 18(修复问题)上测试。
时间线:
- 修复就绪:现在
- QA 验证:1 小时
- App Store 提交:今天
- 用户可用:24-48 小时(加急审核)
受影响用户的变通方案:强制退出并重新启动应用通常可以暂时清除问题。
iOS 18 导航修复
根本原因:NavigationPath 在异步上下文中修改而没有 @MainActor 隔离。
iOS 17 比较宽松,iOS 18 强制执行。
应用的修复:
- 为 Router 类添加了 @MainActor
- 更新了所有 path.append/removeLast 调用以进行 MainActor 隔离
- 添加了 Swift 6 并发检查以捕获未来问题
更改的文件:Router.swift、ContentView.swift、DeepLinkHandler.swift
需要的测试:
- 所有导航流程
- 冷启动的深度链接
- 带有导航状态的标签页切换
- 带有导航状态的后台/前台切换
时间成本 10-20 分钟
NavigationSplitView 在紧凑(iPhone)和常规(iPad)尺寸类别之间自动适应。大多数问题出现是因为开发者只在 iPhone 上测试,在那里它会折叠为 NavigationStack。
// 检查 1:columnVisibility 是否控制可见性?
@State private var columnVisibility: NavigationSplitViewVisibility = .all
NavigationSplitView(columnVisibility: $columnVisibility) {
// 侧边栏
} detail: {
// 详情
}
// 检查 2:你是否处于紧凑尺寸类别?(iPhone 或 iPad 滑出视图)
// 在紧凑模式下,NavigationSplitView 自动折叠为 NavigationStack
// 侧边栏成为堆栈的根
columnVisibility 以控制初始状态(.all、.doubleColumn、.detailOnly)navigationSplitViewStyle(.balanced) 或 .prominentDetail 来控制列比例NavigationSplitView {
List(items, selection: $selectedItem) { item in
Text(item.name)
}
} detail: {
if let selectedItem {
ItemDetailView(item: selectedItem)
} else {
ContentUnavailableView("Select an Item",
systemImage: "sidebar.left",
description: Text("Choose an item from the sidebar"))
}
}
关键见解:iPad 在启动时立即显示详情列。没有默认视图,它就是空白的。iPhone 不显示这个,因为它从侧边栏开始。
Core principle 85% of navigation problems stem from path state management errors, view identity issues, or placement mistakes—not SwiftUI defects.
SwiftUI's navigation system is used by millions of apps and handles complex navigation patterns reliably. If your navigation is failing, not responding, or behaving unexpectedly, the issue is almost always in how you're managing navigation state, not the framework itself.
This skill provides systematic diagnostics to identify root causes in minutes, not hours.
If you see ANY of these, suspect a code issue, not framework breakage:
Navigation tap does nothing (link present but doesn't push)
Back button pops to wrong screen or root
Deep link opens app but shows wrong screen
Navigation state lost when switching tabs
Navigation state lost when app backgrounds
Same NavigationLink pushes twice
Navigation animation stuck or janky
Crash with navigationDestination in stack trace
❌ FORBIDDEN "SwiftUI navigation is broken, let's wrap UINavigationController"
Critical distinction NavigationStack behavior is deterministic. If it's not working, you're modifying state incorrectly, have view identity issues, or navigationDestination is misplaced.
ALWAYS run these checks FIRST (before changing code):
// 1. Add NavigationPath logging
NavigationStack(path: $path) {
RootView()
.onChange(of: path.count) { oldCount, newCount in
print("📍 Path changed: \(oldCount) → \(newCount)")
// If this never fires, link isn't modifying path
// If it fires unexpectedly, something else modifies path
}
}
// 2. Check navigationDestination is visible
// Put temporary print in destination closure
.navigationDestination(for: Recipe.self) { recipe in
let _ = print("🔗 Destination for Recipe: \(recipe.name)")
RecipeDetail(recipe: recipe)
}
// If this never prints, destination isn't being evaluated
// 3. Check NavigationLink is inside NavigationStack
// Visual inspection: Trace from NavigationLink up view hierarchy
// Must hit NavigationStack, not another container first
// 4. Check path state location
// @State must be in stable view (not recreated each render)
// Must be @State, @StateObject, or @Observable — not local variable
// 5. Test basic case in isolation
// Create minimal reproduction
NavigationStack {
NavigationLink("Test", value: "test")
.navigationDestination(for: String.self) { str in
Text("Pushed: \(str)")
}
}
// If this works, problem is in your specific setup
| Observation | Diagnosis | Next Step |
|---|---|---|
| onChange never fires on tap | NavigationLink not in NavigationStack hierarchy | Pattern 1a |
| onChange fires but view doesn't push | navigationDestination not found/loaded | Pattern 1b |
| onChange fires, view pushes, then immediate pop | View identity issue or path modification | Pattern 2a |
| Path changes unexpectedly (not from tap) | External code modifying path | Pattern 2b |
| Deep link path.append() doesn't navigate | Timing issue or wrong thread | Pattern 3b |
| State lost on tab switch | NavigationStack shared across tabs | Pattern 4a |
| Works first time, fails on return | View recreation issue | Pattern 5a |
Before changing ANY code, identify ONE of these:
Use this to reach the correct diagnostic pattern in 2 minutes:
Navigation problem?
├─ Navigation tap does nothing?
│ ├─ NavigationLink inside NavigationStack?
│ │ ├─ No → Pattern 1a (Link outside Stack)
│ │ └─ Yes → Check navigationDestination
│ │
│ ├─ navigationDestination registered?
│ │ ├─ Inside lazy container? → Pattern 1b (Lazy Loading)
│ │ ├─ Type mismatch? → Pattern 1c (Type Registration)
│ │ └─ Blocked by sheet/popover? → Pattern 1d (Modal Blocking)
│ │
│ └─ Using view-based link?
│ └─ → Pattern 1e (Deprecated API)
│
├─ Unexpected pop back?
│ ├─ Immediate pop after push?
│ │ ├─ View body recreating path? → Pattern 2a (Path Recreation)
│ │ ├─ @State in wrong view? → Pattern 2a (State Location)
│ │ └─ ForEach id changing? → Pattern 2c (Identity Change)
│ │
│ ├─ Pop when shouldn't?
│ │ ├─ External code calling removeLast? → Pattern 2b (Unexpected Modification)
│ │ ├─ Task cancelled? → Pattern 2b (Async Cancellation)
│ │ └─ MainActor issue? → Pattern 2d (Threading)
│ │
│ └─ Back button behavior wrong?
│ └─ → Pattern 2e (Stack Corruption)
│
├─ Deep link not working?
│ ├─ URL not received?
│ │ ├─ onOpenURL not called? → Check URL scheme in Info.plist
│ │ └─ Universal Links issue? → Check apple-app-site-association
│ │
│ ├─ URL received, path not updated?
│ │ ├─ path.append not on MainActor? → Pattern 3a (Threading)
│ │ ├─ Timing issue (app not ready)? → Pattern 3b (Initialization)
│ │ └─ NavigationStack not created yet? → Pattern 3b (Lifecycle)
│ │
│ └─ Path updated, wrong screen shown?
│ ├─ Wrong path order? → Pattern 3c (Path Construction)
│ ├─ Wrong type appended? → Pattern 3c (Type Mismatch)
│ └─ Item not found? → Pattern 3d (Data Resolution)
│
├─ State lost?
│ ├─ Lost on tab switch?
│ │ ├─ Shared NavigationStack? → Pattern 4a (Shared State)
│ │ └─ Tab recreation? → Pattern 4a (Tab Identity)
│ │
│ ├─ Lost on background/foreground?
│ │ ├─ No SceneStorage? → Pattern 4b (No Persistence)
│ │ └─ Decode failure? → Pattern 4c (Decode Error)
│ │
│ └─ Lost on rotation/size change?
│ └─ → Pattern 4d (Layout Recreation)
│
├─ NavigationSplitView issue?
│ ├─ Sidebar not visible on iPad?
│ │ ├─ columnVisibility not set? → Pattern 6a (Column Visibility)
│ │ └─ Compact size class? → Pattern 6a (Automatic Adaptation)
│ │
│ ├─ Detail shows blank on iPad?
│ │ ├─ No default detail view? → Pattern 6b (Missing Detail)
│ │ └─ Selection binding nil? → Pattern 6b (Selection State)
│ │
│ └─ Works on iPhone, broken on iPad?
│ └─ → Pattern 6c (Platform Adaptation)
│
└─ Crash?
├─ EXC_BAD_ACCESS in navigation code?
│ └─ → Pattern 5a (Memory Issue)
│
├─ Fatal error: type not registered?
│ └─ → Pattern 5b (Missing Destination)
│
└─ Decode failure on restore?
└─ → Pattern 5c (Restoration Crash)
Before proceeding to a pattern:
Time cost 5-10 minutes
// Check view hierarchy — NavigationLink must be INSIDE NavigationStack
// ❌ WRONG — Link outside stack
struct ContentView: View {
var body: some View {
VStack {
NavigationLink("Go", value: "test") // Outside stack!
NavigationStack {
Text("Root")
}
}
}
}
// Check: Add background color to NavigationStack
NavigationStack {
Color.red // If link is on red, it's inside
}
// ✅ CORRECT — Link inside stack
struct ContentView: View {
var body: some View {
NavigationStack {
VStack {
NavigationLink("Go", value: "test") // Inside stack
Text("Root")
}
.navigationDestination(for: String.self) { str in
Text("Pushed: \(str)")
}
}
}
}
Time cost 10-15 minutes
// ❌ WRONG — Destination inside lazy container (may not be loaded)
ScrollView {
LazyVStack {
ForEach(items) { item in
NavigationLink(item.name, value: item)
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item) // May not be evaluated!
}
}
}
}
// ✅ CORRECT — Destination outside lazy container
ScrollView {
LazyVStack {
ForEach(items) { item in
NavigationLink(item.name, value: item)
}
}
}
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item) // Always available
}
Time cost 10 minutes
// Check: Value type must EXACTLY match destination type
// Link uses Recipe
NavigationLink(recipe.name, value: recipe) // value is Recipe
// Destination registered for... Recipe.ID?
.navigationDestination(for: Recipe.ID.self) { id in // ❌ Wrong type!
RecipeDetail(id: id)
}
// Match types exactly
NavigationLink(recipe.name, value: recipe) // Recipe
.navigationDestination(for: Recipe.self) { recipe in // ✅ Recipe
RecipeDetail(recipe: recipe)
}
// OR change link to use ID
NavigationLink(recipe.name, value: recipe.id) // Recipe.ID
.navigationDestination(for: Recipe.ID.self) { id in // ✅ Recipe.ID
RecipeDetail(id: id)
}
print(type(of: value))Time cost 15-20 minutes
// ❌ WRONG — Path created in view body (reset every render)
struct ContentView: View {
var body: some View {
let path = NavigationPath() // Recreated every time!
NavigationStack(path: .constant(path)) {
// ...
}
}
}
// ❌ WRONG — @State in child view that gets recreated
struct ParentView: View {
@State var showChild = true
var body: some View {
if showChild {
ChildView() // Recreated when showChild toggles
}
}
}
struct ChildView: View {
@State var path = NavigationPath() // Lost when ChildView recreated
// ...
}
// ✅ CORRECT — @State at stable level
struct ContentView: View {
@State private var path = NavigationPath() // Persists across renders
var body: some View {
NavigationStack(path: $path) {
RootView()
}
}
}
// ✅ CORRECT — @StateObject for ObservableObject
struct ContentView: View {
@StateObject private var navModel = NavigationModel()
var body: some View {
NavigationStack(path: $navModel.path) {
RootView()
}
}
}
Time cost 10-15 minutes
// ❌ WRONG — Modifying path from background task
func loadAndNavigate() async {
let recipe = await fetchRecipe()
path.append(recipe) // ⚠️ Not on MainActor!
}
// Check: Search for path.append, path.removeLast outside @MainActor context
// ✅ CORRECT — Ensure MainActor
@MainActor
func loadAndNavigate() async {
let recipe = await fetchRecipe()
path.append(recipe) // ✅ MainActor isolated
}
// OR explicitly dispatch
func loadAndNavigate() async {
let recipe = await fetchRecipe()
await MainActor.run {
path.append(recipe)
}
}
// ✅ BEST — Use @Observable with @MainActor
@Observable
@MainActor
class Router {
var path = NavigationPath()
func navigate(to value: any Hashable) {
path.append(value)
}
}
Time cost 15-20 minutes
// ❌ WRONG — May be called before NavigationStack exists
.onOpenURL { url in
handleDeepLink(url) // NavigationStack may not be rendered yet
}
func handleDeepLink(_ url: URL) {
path.append(parsedValue) // Modifies path that doesn't exist yet
}
// ✅ CORRECT — Defer deep link handling
@State private var pendingDeepLink: URL?
@State private var isReady = false
var body: some View {
NavigationStack(path: $path) {
RootView()
.onAppear {
isReady = true
if let url = pendingDeepLink {
handleDeepLink(url)
pendingDeepLink = nil
}
}
}
.onOpenURL { url in
if isReady {
handleDeepLink(url)
} else {
pendingDeepLink = url // Queue for later
}
}
}
Time cost 10-15 minutes
// ❌ WRONG — Wrong order (child before parent)
// URL: myapp://category/desserts/recipe/apple-pie
func handleDeepLink(_ url: URL) {
path.append(recipe) // Recipe pushed first
path.append(category) // Category pushed second — WRONG ORDER
}
// User sees Category screen, not Recipe screen
// ✅ CORRECT — Parent before child
func handleDeepLink(_ url: URL) {
path.removeLast(path.count) // Clear existing
// Build hierarchy: parent → child
path.append(category) // First: Category
path.append(recipe) // Second: Recipe (shows this screen)
}
// For complex paths, build array first
var newPath: [any Hashable] = []
// Parse URL segments...
newPath.append(category)
newPath.append(subcategory)
newPath.append(item)
// Then apply
path = NavigationPath(newPath)
Time cost 15-20 minutes
// ❌ WRONG — Single NavigationStack wrapping TabView
NavigationStack(path: $path) {
TabView {
Tab("Home") { HomeView() }
Tab("Settings") { SettingsView() }
}
}
// All tabs share same navigation — state mixed/lost
// ❌ WRONG — Same @State used across tabs
@State var path = NavigationPath() // Shared
TabView {
Tab("Home") {
NavigationStack(path: $path) { ... } // Uses shared path
}
Tab("Settings") {
NavigationStack(path: $path) { ... } // Same path!
}
}
// ✅ CORRECT — Each tab has own NavigationStack
TabView {
Tab("Home", systemImage: "house") {
NavigationStack { // Own stack
HomeView()
.navigationDestination(for: HomeItem.self) { ... }
}
}
Tab("Settings", systemImage: "gear") {
NavigationStack { // Own stack
SettingsView()
.navigationDestination(for: SettingItem.self) { ... }
}
}
}
// For per-tab path tracking:
struct HomeTab: View {
@State private var path = NavigationPath() // Tab-specific
var body: some View {
NavigationStack(path: $path) {
HomeView()
}
}
}
Time cost 15-20 minutes
// ❌ WRONG — No persistence mechanism
@State private var path = NavigationPath()
// Path lost when app terminates
// ✅ CORRECT — Use SceneStorage + Codable
struct ContentView: View {
@StateObject private var navModel = NavigationModel()
@SceneStorage("navigation") private var savedData: Data?
var body: some View {
NavigationStack(path: $navModel.path) {
RootView()
}
.task {
// Restore on appear
if let data = savedData {
navModel.restore(from: data)
}
// Save on changes
for await _ in navModel.objectWillChange.values {
savedData = navModel.encoded()
}
}
}
}
@MainActor
class NavigationModel: ObservableObject {
@Published var path = NavigationPath()
func encoded() -> Data? {
guard let codable = path.codable else { return nil }
return try? JSONEncoder().encode(codable)
}
func restore(from data: Data) {
guard let codable = try? JSONDecoder().decode(
NavigationPath.CodableRepresentation.self,
from: data
) else { return }
path = NavigationPath(codable)
}
}
Time cost 10-15 minutes
// Every type pushed on path needs a destination
// You push Recipe
path.append(recipe) // Recipe type
// But only registered Category
.navigationDestination(for: Category.self) { ... }
// No destination for Recipe!
// Register ALL types you might push
NavigationStack(path: $path) {
RootView()
.navigationDestination(for: Category.self) { category in
CategoryView(category: category)
}
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
.navigationDestination(for: Chef.self) { chef in
ChefProfile(chef: chef)
}
}
// Or use enum route type for single registration
enum AppRoute: Hashable {
case category(Category)
case recipe(Recipe)
case chef(Chef)
}
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .category(let cat): CategoryView(category: cat)
case .recipe(let recipe): RecipeDetail(recipe: recipe)
case .chef(let chef): ChefProfile(chef: chef)
}
}
Time cost 15-20 minutes
// ❌ WRONG — Force unwrap decode
func restore(from data: Data) {
let codable = try! JSONDecoder().decode( // 💥 Crashes!
NavigationPath.CodableRepresentation.self,
from: data
)
path = NavigationPath(codable)
}
// Crash reasons:
// - Saved path contains type that no longer exists
// - Codable encoding changed between versions
// - Saved item was deleted
// ✅ CORRECT — Graceful decode with fallback
func restore(from data: Data) {
do {
let codable = try JSONDecoder().decode(
NavigationPath.CodableRepresentation.self,
from: data
)
path = NavigationPath(codable)
} catch {
print("Navigation restore failed: \(error)")
path = NavigationPath() // Start fresh
// Optionally clear bad saved data
}
}
// ✅ BETTER — Store IDs, resolve to objects
class NavigationModel: ObservableObject, Codable {
var selectedIds: [String] = [] // Store IDs
func resolvedPath(dataModel: DataModel) -> NavigationPath {
var path = NavigationPath()
for id in selectedIds {
if let item = dataModel.item(withId: id) {
path.append(item)
}
// Missing items silently skipped
}
return path
}
}
"It's an iOS 18 bug, wait for Apple to fix"
"Let's wrap UINavigationController"
"Add retry logic for navigation"
"Roll back to pre-iOS 18 version"
You have 2 hours to provide CTO with:
// Release build with diagnostic logging
#if DEBUG || DIAGNOSTIC
NavigationStack(path: $path) {
// ...
}
.onChange(of: path.count) { old, new in
Analytics.log("nav_path_change", ["old": old, "new": new])
}
#endif
// Check analytics for:
// - path.count going to 0 unexpectedly → Path recreation
// - path.count increasing but no push → Missing destination
// - No path changes at all → Link not firing
// iOS 18 changes that affect navigation:
// 1. Stricter MainActor enforcement
// 2. Changes to view identity in TabView
// 3. New navigation lifecycle timing
// Most common iOS 18 issue:
// Code that worked by accident now fails
// Check: Any path modifications in async contexts without @MainActor?
Task {
let result = await fetch()
path.append(result) // ⚠️ iOS 18 stricter about this
}
// Root cause found: NavigationPath modified from async context
// iOS 17 was lenient, iOS 18 enforces MainActor properly
// ❌ Old code (worked on iOS 17, breaks on iOS 18)
func loadAndNavigate() async {
let recipe = await fetchRecipe()
path.append(recipe) // Race condition
}
// ✅ Fix: Explicit MainActor isolation
@MainActor
func loadAndNavigate() async {
let recipe = await fetchRecipe()
path.append(recipe) // ✅ Safe
}
// OR: Annotate entire class
@Observable
@MainActor
class Router {
var path = NavigationPath()
func navigate(to value: any Hashable) {
path.append(value)
}
}
// 1. Test on iOS 17 device — still works
// 2. Test on iOS 18 device — now works
// 3. Test all navigation paths
// 4. Submit expedited review
// Expedited review justification:
// "Critical bug fix for iOS 18 compatibility affecting 20% of users"
Root cause identified: Navigation code wasn't properly isolated
to the main thread. iOS 18 enforces this more strictly than iOS 17.
Fix: Add @MainActor annotation to navigation code.
Already tested on iOS 17 (no regression) and iOS 18 (fixes issue).
Timeline:
- Fix ready: Now
- QA validation: 1 hour
- App Store submission: Today
- Available to users: 24-48 hours (expedited review)
Workaround for affected users: Force quit and relaunch app
often clears the issue temporarily.
iOS 18 Navigation Fix
Root cause: NavigationPath modifications in async contexts
without @MainActor isolation. iOS 17 was permissive, iOS 18 enforces.
Fix applied:
- Added @MainActor to Router class
- Updated all path.append/removeLast calls to be MainActor-isolated
- Added Swift 6 concurrency checking to catch future issues
Files changed: Router.swift, ContentView.swift, DeepLinkHandler.swift
Testing needed:
- All navigation flows
- Deep links from cold start
- Tab switching with navigation state
- Background/foreground with navigation state
Time cost 10-20 minutes
NavigationSplitView adapts automatically between compact (iPhone) and regular (iPad) size classes. Most issues arise because developers test only on iPhone, where it collapses to a NavigationStack.
// Check 1: Is columnVisibility controlling visibility?
@State private var columnVisibility: NavigationSplitViewVisibility = .all
NavigationSplitView(columnVisibility: $columnVisibility) {
// sidebar
} detail: {
// detail
}
// Check 2: Are you in compact size class? (iPhone or iPad slide-over)
// In compact, NavigationSplitView collapses to NavigationStack automatically
// The sidebar becomes the root of the stack
columnVisibility to control initial state (.all, .doubleColumn, .detailOnly)navigationSplitViewStyle(.balanced) or .prominentDetail to control column proportionsNavigationSplitView {
List(items, selection: $selectedItem) { item in
Text(item.name)
}
} detail: {
if let selectedItem {
ItemDetailView(item: selectedItem)
} else {
ContentUnavailableView("Select an Item",
systemImage: "sidebar.left",
description: Text("Choose an item from the sidebar"))
}
}
Key insight : iPad shows the detail column immediately on launch. Without a default view, it's blank. iPhone doesn't show this because it starts on the sidebar.
NavigationSplitView uses different navigation models per size class:
Regular (iPad full screen): Side-by-side columns, selection drives detail
Compact (iPhone, iPad slide-over): Collapses to NavigationStack, selection pushes
// Common mistake: using NavigationLink inside NavigationSplitView sidebar // This creates DOUBLE navigation on iPad (link push + selection) // Fix: Use List(selection:) binding, not NavigationLink NavigationSplitView { List(items, selection: $selectedID) { item in // ✅ selection binding Text(item.name) } } detail: { // driven by selectedID }
Test on both iPhone AND iPad before shipping. Most NavigationSplitView bugs are platform-specific.
| Symptom | Likely Cause | First Check | Pattern | Fix Time |
|---|---|---|---|---|
| Link tap does nothing | Link outside stack | View hierarchy | 1a | 5-10 min |
| Intermittent navigation failure | Destination in lazy container | Destination placement | 1b | 10-15 min |
| Works for some types, not others | Type mismatch | Print type(of:) | 1c | 10 min |
| Push then immediate pop | Path recreated | @State location | 2a | 15-20 min |
| Random unexpected pops | External path modification | Add logging | 2b | 15-20 min |
| Works on MainActor, fails in Task |
Problem Destination not loaded when needed (lazy evaluation).
Why it fails LazyVStack/ForEach don't evaluate all children. Destination may not exist when link is tapped.
// Move destination OUTSIDE lazy container
List {
ForEach(items) { item in
NavigationLink(item.name, value: item)
}
}
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item)
}
Problem NavigationView deprecated, different behavior across versions.
Why it fails No NavigationPath support, can't programmatically navigate or deep link reliably.
NavigationView with NavigationStack or NavigationSplitViewNavigationLink(title, value:) instead of view-basedProblem Path reset every access.
Why it fails var body is called repeatedly. Creating path there means it's reset constantly.
// Use @State, not computed
@State private var path = NavigationPath() // ✅ Persists
// NOT
var path: NavigationPath { NavigationPath() } // ❌ Reset every time
Problem Crash when saved navigation data is invalid.
Why it fails Data model changes, items deleted, encoding format changes between app versions.
try? or do/catch for decodeProblem Deep link on cold start fails.
Why it fails onOpenURL may fire before NavigationStack is rendered.
onAppear of NavigationStackisReady flag patternswiftui-nav skill — Discipline-enforcing anti-patterns:
swiftui-nav-ref skill — Complete API documentation:
swift-concurrency skill — If MainActor issues:
Last Updated 2025-12-05 Status Production-ready diagnostics Tested Diagnostic patterns validated against common navigation issues
Weekly Installs
105
Repository
GitHub Stars
601
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode90
codex83
claude-code83
cursor82
gemini-cli81
github-copilot77
解决 Docker 沙盒 npm 安装崩溃:sandbox-npm-install 技能详解与使用指南
701 周安装
Himalaya 命令行电子邮件客户端 | 终端邮件管理工具 | IMAP/SMTP 配置指南
706 周安装
Sonos CLI 命令行工具:本地网络控制 Sonos 音箱,支持分组、收藏、Spotify 搜索
708 周安装
SwiftData 教程:iOS 26+ 数据持久化、查询与管理完整指南
709 周安装
Lightpanda 无头浏览器 - Zig 语言构建,AI 自动化与网页抓取利器,速度提升11倍
693 周安装
Apple端侧AI开发指南:Core ML、Foundation Models、MLX Swift与llama.cpp选择与优化
699 周安装
| Threading issue |
| Check @MainActor |
| 2d |
| 10-15 min |
| Deep link doesn't navigate | Not on MainActor | Thread check | 3a | 15-20 min |
| Deep link from cold start fails | Timing/lifecycle | Add pendingDeepLink | 3b | 15-20 min |
| Deep link shows wrong screen | Path order wrong | Print path contents | 3c | 10-15 min |
| State lost on tab switch | Shared NavigationStack | Check Tab structure | 4a | 15-20 min |
| State lost on background | No persistence | Add SceneStorage | 4b | 20-25 min |
| Crash on launch (decode) | Force unwrap decode | Error handling | 5c | 15-20 min |
| "No destination found" crash | Missing registration | List all types | 5b | 10-15 min |
| Sidebar missing on iPad | columnVisibility | Check binding | 6a | 10-15 min |
| Blank detail on iPad | No default detail | Add ContentUnavailableView | 6b | 10 min |
| Works iPhone, broken iPad | Platform adaptation | Test both size classes | 6c | 15-20 min |