NavigationStack + 枚举 Route 是 iOS 26 的标准做法,避免用字符串或 AnyView 传递目的地。
用 @Observable 的 Router 类 集中管理 NavigationPath,视图只负责调用 router.push()、popToRoot()。
深链接的关键是把 URL 解析成 Route 数组,再整批 append 到 path,而不是一层层地 push。
iOS 26 新增 navigationSubtitle(_:)、ToolbarSpacer、swipeActionsContainer 与 Liquid Glass 搜索栏,全部与 NavigationStack 配合得很自然。
TabView 里每个 Tab 都应有自己独立的 NavigationStack 与 Router,共享 path 是最常见的架构 Bug。
VoiceOver 在 push 后不会自动聚焦标题栏,需要用 UIAccessibility.post(.screenChanged, ...) 明确通知焦点变化。
本页目录
为什么在 iOS 26 依然要用 NavigationStack?
用枚举定义类型安全的 Route
Router 模式:把导航逻辑搬出视图
程序化导航:push、pop、popToRoot
深链接:把 URL 变成路由栈
TabView 与 NavigationStack 的正确组合
iOS 26 新 API:navigationSubtitle、ToolbarSpacer
VoiceOver 无障碍:让每次导航都被听见
用 Codable 路由做状态恢复
常见坑与调试清单
为什么在 iOS 26 依然要用 NavigationStack?
老实讲,NavigationStack 已经不是 "NavigationView 的替代品" 那么简单。它是 SwiftUI 里唯一支持 数据驱动导航 的容器。
老 NavigationView 的致命问题在于导航状态藏在视图层级里:一旦 NavigationLink 被卸载,回退就丢状态;程序化 push 只能靠一堆布尔 isActive 绑定拼装;深链接更是要延迟触发 NavigationLink 才能生效。iOS 16 的 NavigationStack(path:) 把这些痛点一次解决,路径变成一段可编辑、可持久化、可测试的数据。
iOS 26 又在此基础上补齐了 NavigationStack 官方文档 里最常被吐槽的三个短板:副标题、工具栏间距和搜索栏视觉。它现在真正达到了 UIKit UINavigationController 的表现力,同时保留了 SwiftUI 声明式的开发体验。如果你的目标版本是 iOS 17 及以上,本文所有示例都可以直接使用;iOS 16 只需把 @Observable 换成 ObservableObject,几乎无痛移植。想复习 @Observable 宏与 MainActor 默认隔离的关系,可以顺便看下我这篇 Swift 6.2 Approachable Concurrency 完全指南 ,它对本文的 Router 生命周期理解很有帮助。
用枚举定义类型安全的 Route
数据驱动导航的第一步,是把 "去哪里" 建模成一个 Hashable、最好也 Codable 的类型。字符串或 Int ID 都很脆弱:编译器无法保证目的地存在,也没办法在编译期检查参数类型。我在生产项目里几乎只用枚举:
enum AppRoute: Hashable, Codable {
case home
case articleList(categoryId: String)
case articleDetail(id: String, source: DetailSource)
case profile(userId: String)
case settings
case about
enum DetailSource: String, Hashable, Codable {
case list, search, deeplink, push
}
}
关联值让每一个目的地都携带自己需要的参数。编译器强制你在 switch 里覆盖所有分支,增加一个新页面就一定会在集中式的 navigationDestination 里报错,逼你处理完整。Codable 一致性算是白拿的,等到我们做 状态恢复 或 深链接 时不用再改类型。
关键的绑定发生在 NavigationStack 的根视图上,用 navigationDestination(for:) 一次注册全部路由:
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .home:
HomeView()
case .articleList(let categoryId):
ArticleListView(categoryId: categoryId)
case .articleDetail(let id, let source):
ArticleDetailView(id: id, source: source)
case .profile(let userId):
ProfileView(userId: userId)
case .settings:
SettingsView()
case .about:
AboutView()
}
}
}
把 navigationDestination 放在 根视图 (也就是 NavigationStack 的直接子视图)里,深链接才能一次 append 多层路由。放在中间某层视图里会导致 "只有当上一层被 push 之后下一层才能匹配",这是初学者最常见的深链接失败原因。
Router 模式:把导航逻辑搬出视图
视图应该只关心 "用户点了什么",不该关心 "接下来去哪、以什么方式去"。这份分工在 iOS 上叫做 Coordinator,在 SwiftUI 圈里通常叫 Router。用 iOS 17+ 的 @Observable 宏可以让 Router 非常轻:
import SwiftUI
import Observation
@MainActor
@Observable
final class AppRouter {
var path: [AppRoute] = []
var presentedSheet: AppSheet?
var presentedFullScreen: AppRoute?
func push(_ route: AppRoute) {
path.append(route)
}
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
func popToRoot() {
path.removeAll()
}
func replaceStack(with routes: [AppRoute]) {
path = routes
}
func present(_ sheet: AppSheet) {
presentedSheet = sheet
}
}
enum AppSheet: Identifiable, Hashable {
case composer
case share(url: URL)
var id: Self { self }
}
注意两个细节:
我用 [AppRoute] 而不是通用的 NavigationPath。前者是 同质路径 ,可以 path[2] 直接读到某一层的具体值,也能在测试里直接断言。NavigationPath 支持异构 Hashable 类型(一个栈里混多种路由),代价是无法读到具体值,只有想混合多种独立路由类型时才有必要。
类被标注为 @MainActor。SwiftUI 视图和 @Observable 状态都在主线程上跑,显式声明能让 Swift 6.2 的严格并发检查安心,也和 Approachable Concurrency 的默认隔离行为一致。
在 App 入口注入一次就能全局共享:
@main
struct SwiftCraftedApp: App {
@State private var router = AppRouter()
var body: some Scene {
WindowGroup {
RootView()
.environment(router)
}
}
}
struct RootView: View {
@Environment(AppRouter.self) private var router
var body: some View {
@Bindable var router = router
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
routeView(for: route)
}
.sheet(item: $router.presentedSheet) { sheet in
sheetView(for: sheet)
}
}
}
}
@Bindable 是 iOS 17+ 的关键动作:@Observable 类型不能直接用 $ 取 binding,先声明一个局部 @Bindable 引用才行。忘了这一步是把 NavigationPath 传给 NavigationStack(path:) 时最常见的编译错误。我上个项目里刚重构完 Router 时就在这里踩了一次。
提示: 不要在 Router 里持有业务数据(用户信息、文章内容)。Router 只管 "去哪里",具体数据由目的地视图通过依赖注入或数据仓库自己取。这条边界一旦守住,Router 单元测试可以完全脱离业务层,非常轻。
程序化导航:push、pop、popToRoot
把导航当数组来操作,是 NavigationStack 最爽的地方。任何视图都能通过 @Environment(AppRouter.self) 拿到 Router 并直接改路径:
struct ArticleCardView: View {
let article: Article
@Environment(AppRouter.self) private var router
var body: some View {
Button {
router.push(.articleDetail(id: article.id, source: .list))
} label: {
ArticleCardLabel(article: article)
}
.buttonStyle(.plain)
}
}
更高级的模式,比如一次跳多层、跳到某个具体祖先,也都只是数组操作:
// 一次进入 "分类 → 详情" 两层,深链接常见
router.replaceStack(with: [
.articleList(categoryId: "swiftui"),
.articleDetail(id: "abc123", source: .deeplink)
])
// 弹到指定层级:保留前 2 层,把之后的都弹掉
if router.path.count > 2 {
router.path.removeLast(router.path.count - 2)
}
// 弹到第一次出现某个路由为止
if let index = router.path.firstIndex(where: {
if case .articleList = $0 { return true } else { return false }
}) {
router.path = Array(router.path.prefix(through: index))
}
因为 path 是数组,你可以做任意变形。老 UIKit 里那些 popToViewController、setViewControllers 的 API 组合,在 SwiftUI 里被简化成了一次赋值。
警告: 不要在 onAppear 里读取 path.last 来判断 "当前页面是不是我"。onAppear 触发时机与栈的实际变化不完全同步,正确姿势是把当前路由通过环境值传给子视图,或者让子视图知道自己的路由参数。
深链接:把 URL 变成路由栈
深链接的本质,是把一个字符串(Universal Link、Push Payload、Widget URL)翻译成一整段 [AppRoute],然后一次性 replaceStack(with:)。把这段逻辑收敛到 Router 或独立的 DeepLinkParser 里,测试起来才可控:
enum DeepLinkParser {
/// swiftcrafted://article/abc123?source=push
/// https://swiftcrafted.dev/zh/article/abc123
static func parse(_ url: URL) -> [AppRoute]? {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let path = url.pathComponents.filter { $0 != "/" }
switch path.first {
case "article":
guard let id = path.dropFirst().first else { return nil }
let source = components?.queryItems?.first { $0.name == "source" }?.value
let detailSource = AppRoute.DetailSource(rawValue: source ?? "deeplink") ?? .deeplink
return [
.articleList(categoryId: "all"),
.articleDetail(id: id, source: detailSource)
]
case "profile":
guard let userId = path.dropFirst().first else { return nil }
return [.profile(userId: userId)]
case "settings":
return [.settings]
default:
return nil
}
}
}
然后在根 App 用 onOpenURL 与 onContinueUserActivity 双通道接住入口:
WindowGroup {
RootView()
.environment(router)
.onOpenURL { url in
if let routes = DeepLinkParser.parse(url) {
router.replaceStack(with: routes)
}
}
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
guard let url = activity.webpageURL,
let routes = DeepLinkParser.parse(url) else { return }
router.replaceStack(with: routes)
}
}
冷启动时的时序坑 :用户活动可能在 NavigationStack 完成首次布局前就到达。iOS 17+ 的写法非常宽容,直接给 @Observable 属性赋值就行,SwiftUI 自己会等到栈准备好再应用。但如果你要 push 到某个必须先加载数据的视图,请让目的地视图自己在 task {} 里加载,不要在 Router 里同步获取数据。
iOS 26 的 Liquid Glass 视觉语言 让深链接跳转有了新的 zoom / morph 转场动画。如果你还没接触过它的基础概念,可以先看看这篇 SwiftUI Liquid Glass 完全指南 。
TabView 与 NavigationStack 的正确组合
几乎所有生产 App 都是 TabView 加多个独立的 NavigationStack。这里最容易犯的错,是让所有 Tab 共享一个 Router 或一个 NavigationPath。一旦切换 Tab,栈就串味了。正确姿势是每个 Tab 一个自己的 Router:
@MainActor
@Observable
final class TabRouters {
var home = AppRouter()
var search = AppRouter()
var profile = AppRouter()
}
struct RootTabView: View {
@State private var routers = TabRouters()
@State private var selectedTab: Tab = .home
enum Tab: Hashable { case home, search, profile }
var body: some View {
TabView(selection: $selectedTab) {
NavigationStack(path: bindingFor(\.home)) {
HomeView()
.navigationDestination(for: AppRoute.self) { routeView(for: $0) }
}
.environment(routers.home)
.tabItem { Label("首页", systemImage: "house") }
.tag(Tab.home)
NavigationStack(path: bindingFor(\.search)) {
SearchView()
.navigationDestination(for: AppRoute.self) { routeView(for: $0) }
}
.environment(routers.search)
.tabItem { Label("搜索", systemImage: "magnifyingglass") }
.tag(Tab.search)
}
}
private func bindingFor(_ keyPath: WritableKeyPath) -> Binding<[AppRoute]> {
Binding(
get: { routers[keyPath: keyPath].path },
set: { routers[keyPath: keyPath].path = $0 }
)
}
}
iOS 系统级的手势(比如再次点击当前 Tab 图标返回根)是免费的。只要每个 Tab 有独立的 NavigationStack,SwiftUI 会自动帮你 popToRoot,你自己不需要写任何监听逻辑。
iOS 26 新 API:navigationSubtitle、ToolbarSpacer 与搜索
iOS 26 给 NavigationStack 加的三件套,解决了长期以来 UIKit 用户羡慕的功能。第一件是 navigationSubtitle(_:),终于不用在标题里塞两行文字了:
ArticleDetailView(article: article)
.navigationTitle(article.title)
.navigationSubtitle("\(article.readingMinutes) 分钟 · \(article.author)")
第二件是 ToolbarSpacer,它让工具栏按钮的分组和间距变得声明式:
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { router.pop() }
}
ToolbarSpacer(.flexible, placement: .topBarTrailing)
ToolbarItem(placement: .topBarTrailing) {
Button { router.present(.share(url: article.url)) } label: {
Image(systemName: "square.and.arrow.up")
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("完成") { router.popToRoot() }
}
}
第三件是搜索 API 与 Liquid Glass 的整合:在 TabView 上给某个 tab 声明 role: .search,iOS 26 会自动把它渲染成右侧独立的搜索胶囊,点击时展开成完整搜索栏。这是达到 App Store 同款体验的最短路径:
TabView(selection: $selectedTab) {
// ... 其他 tab
Tab(role: .search) {
NavigationStack {
SearchView()
.searchable(text: $query, placement: .toolbar)
}
}
.tag(Tab.search)
}
说明: 这些 API 只在 iOS 26+ 可用。iOS 17/18 上要用 if #available 做条件编译;副标题在旧系统里我一般降级为在标题下方渲染一个 ToolbarItem(placement: .principal) 的自定义 VStack。
VoiceOver 无障碍:让每次导航都被听见
NavigationStack 自带一些无障碍能力:返回按钮有自动 label、标题被标记为 heading。但有几个默认行为是 不够好 的,需要你手动补齐。这也是我在每次代码审查里都会挑刺的点。
1. push 之后焦点不会跳到标题
系统的 Push 转场在 VoiceOver 下会把焦点丢在 "第一个可聚焦的子视图",很多时候那就是页面正文的第一个按钮,用户完全不知道自己进了哪个页面。修复方法是显式发一个 .screenChanged 通知,把焦点指向应该被首先朗读的元素:
struct ArticleDetailView: View {
let article: Article
@AccessibilityFocusState private var titleFocus: Bool
var body: some View {
ScrollView {
Text(article.title)
.font(.largeTitle.bold())
.accessibilityAddTraits(.isHeader)
.accessibilityFocused($titleFocus)
Text(article.body)
}
.navigationTitle(article.title)
.task {
// 等待转场动画结束再抢焦点,避免被系统覆盖
try? await Task.sleep(for: .milliseconds(500))
titleFocus = true
}
}
}
@AccessibilityFocusState 是 SwiftUI 原生方案,比手写 UIAccessibility.post(...) 更容易在预览里测试。500 毫秒延迟是我在多台设备上测出来的稳定值,iOS 26 的 Liquid Glass 转场大约 350 毫秒,留 150 毫秒缓冲就能覆盖低电量模式下的降速。
2. 返回按钮不带上下文
默认返回按钮朗读为 "返回, 按钮" 或 "Back Button",用户不知道要回到哪里。在你 push 前,用 navigationBackButtonDisplayMode(.minimal) 隐藏文字,再自定义一个:
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
router.pop()
} label: {
HStack(spacing: 4) {
Image(systemName: "chevron.backward")
Text("文章列表")
}
}
.accessibilityLabel("返回文章列表")
.accessibilityHint("双击返回到 SwiftUI 分类页面")
}
}
.navigationBarBackButtonHidden()
3. 程序化 popToRoot 时朗读消失
当你调用 router.popToRoot(),VoiceOver 有时会静默,因为它认为屏幕没变。发一个明确的公告能救场:
func popToRoot() {
path.removeAll()
#if canImport(UIKit)
UIAccessibility.post(notification: .announcement, argument: "已返回首页")
#endif
}
Apple 在 Accessibility 官方指南 里反复强调:任何 "看得见变化" 的地方都值得配一句 VoiceOver 通告。同样的原则也适用于 SwiftData 数据刷新完成、异步任务失败。想把无障碍做扎实,可以顺便看下 SwiftData 与 CloudKit 同步指南 里关于同步进度提示的部分。
用 Codable 路由做状态恢复
iOS 系统在低内存或 App 长时间挂后台后会杀掉进程。恢复时把用户带回原本的页面栈,能大幅提升留存。因为我们的 AppRoute 已经是 Codable,恢复几乎就是一行代码:
@MainActor
@Observable
final class AppRouter {
var path: [AppRoute] = [] {
didSet { persistIfNeeded() }
}
private static let storageKey = "com.swiftcrafted.routerPath.v2"
init() {
restore()
}
private func persistIfNeeded() {
guard let data = try? JSONEncoder().encode(path) else { return }
UserDefaults.standard.set(data, forKey: Self.storageKey)
}
private func restore() {
guard let data = UserDefaults.standard.data(forKey: Self.storageKey),
let saved = try? JSONDecoder().decode([AppRoute].self, from: data)
else { return }
path = saved
}
}
警告: 存储 key 里带一个版本号(这里的 .v2)。以后 AppRoute 加分支、改关联值时旧数据一定会解码失败,旧 key 直接被忽略比让用户遇到崩溃安全得多。我上一份 App 就是因为漏了这一步,v3 发布当天崩溃率飙了 5 倍,非常惨痛。
常见坑与调试清单
把这份清单贴到 PR 模板里,能避免 90% 的 NavigationStack 事故:
忘了在根视图注册 navigationDestination(for:) :push 时页面空白或直接崩溃。整体只需要一次,放在栈的直接子视图里。
把 navigationDestination 放进 List 或 ForEach :iOS 17 之前会重复注册报警告,之后 SwiftUI 会保留一个,行为不可预测。
Route 里放了非 Hashable 的关联值 (比如闭包、SwiftData 的 @Model 引用):不会编译,或者能编译但运行时崩溃。
在多个 Tab 之间共享同一份 path :切 Tab 时栈错位,最难复现的 Bug 之一。
用 onAppear 触发 push :转场未完成时会被吞掉。改用 task {} 或视图初始化时决定。
Router 存了 @Environment、@State 之外的强引用 :会泄漏视图,Xcode Memory Graph 一抓一个准。
忘了处理 Codable 版本迁移 :App 更新后崩溃 100% 集中在有旧路径的老用户身上。
如果你用 Xcode 26 的 Swift Testing 框架为 Router 写单元测试,可以参考我这篇 Swift Testing 完全指南 。@Test 宏配上 @MainActor Router 非常干净,一个测试只需三行:let router = AppRouter(); router.push(.settings); #expect(router.path.last == .settings)。
常见问题
NavigationStack 和 NavigationView 有什么区别?
NavigationStack 是 iOS 16 引入、数据驱动的导航容器,支持 NavigationPath 程序化控制、深链接和状态恢复;NavigationView 从 iOS 16 开始弃用,其导航状态存在视图层级里,无法可靠地做深链接或恢复。iOS 26 新特性只在 NavigationStack 上提供,新项目一律用 NavigationStack。
如何在 SwiftUI NavigationStack 里返回根视图?
如果 path 是 [AppRoute],直接 path.removeAll();如果是 NavigationPath,用 path = NavigationPath()。把它封装在 Router 的 popToRoot() 方法里,任何子视图都能通过 @Environment 调用。TabView 用户再次点击当前 Tab 图标时系统会自动 popToRoot,无需额外代码。
为什么我的深链接跳到中间某一层就停下来了?
最常见的原因是 navigationDestination(for:) 被写在子视图而不是 NavigationStack 的根视图里。SwiftUI 需要在 append 每一层时找到匹配的 destination,如果注册在子视图上,第二层就永远匹配不到。把所有路由注册集中到根视图,并一次性 path = [routeA, routeB, routeC]。
NavigationPath 和 [Route] 数组该选哪个?
大部分 App 一个 Route 枚举就够了,直接用 [AppRoute]:能读到某层的具体值,方便测试。只有当栈里必须同时容纳多种独立的 Hashable 类型(比如通用组件库要接不同宿主)时,才用 NavigationPath。混合类型的代价是无法直接读某一层的值。
iOS 26 的 navigationSubtitle 在旧系统怎么降级?
用 if #available(iOS 26.0, *) 条件调用,否则在 ToolbarItem(placement: .principal) 里放一个 VStack 手写标题加副标题两行文字。视觉上稍逊,但功能完全兼容 iOS 17。要注意手写方案要额外用 .accessibilityAddTraits(.isHeader) 标记标题行的 heading trait。