Highlight
SwiftUI 在 iOS 16 和 macOS Ventura 中新增
NavigationStack、NavigationSplitView、值驱动NavigationLink、navigationDestination和可持久化导航状态,让开发者可以用数据而非分散的布尔绑定控制 push、分栏、深链接和状态恢复。
核心内容
旧的 SwiftUI 导航 API 以 NavigationLink 直接携带目标 View 为中心。简单跳转可以工作,但一旦要做程序化导航,就要给每个 link 准备单独的绑定。Session 在 01:48 直接指出这个问题:展示某个 link 的 view,需要设置对应的 item.showDetail,这会把导航状态拆散到许多位置。
新的思路是把绑定提升到容器。NavigationStack 持有一个 path,这个集合代表当前栈上所有被 push 的数据值。点击值驱动的 NavigationLink 会把值追加到 path;代码修改 path,就能深链接或回到根视图(02:06)。
Session 用一个食谱 App 做贯穿示例。它先展示单列 push-pop 结构,再展示三列 NavigationSplitView,最后把 split view 和 stack 组合起来,并用 Codable 与 SceneStorage 保存导航状态。
详细内容
单列栈:把 push 变成 path 数据
(06:05)第一个配方是类似 iPhone Settings 或 Apple Watch Find My 的单列导航。根视图列出食谱分类,点进某个食谱后,还可以继续点相关食谱,把新的详情页压到栈上。
官方代码的核心是三件事:NavigationStack(path:)、NavigationLink(value:)、navigationDestination(for:)。
struct PushableStack: View {
@State private var path: [Recipe] = []
@StateObject private var dataModel = DataModel()
var body: some View {
NavigationStack(path: $path) {
List(Category.allCases) { category in
Section(category.localizedName) {
ForEach(dataModel.recipes(in: category)) { recipe in
NavigationLink(recipe.name, value: recipe)
}
}
}
.navigationTitle("Categories")
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
}
.environmentObject(dataModel)
}
}
关键点:
@State private var path: [Recipe] = []保存当前栈上的食谱数据。这个例子里栈只展示Recipe,所以 path 可以是[Recipe]。NavigationStack(path: $path)把栈状态暴露成绑定。代码改path,UI 跟着 push 或 pop。NavigationLink(recipe.name, value: recipe)不再直接写目标视图,只提交一个Recipe值。.navigationDestination(for: Recipe.self)声明Recipe值应该映射成RecipeDetail。- Session 在 09:20 提醒,如果一个栈要展示多种数据类型,可以使用类型擦除的
NavigationPath。
这套结构的好处是清楚。深链接到 Apple Pie 和 Pie Crust,不需要逐个触发 link。只要把两个 Recipe 放进 path,栈就能恢复出两层详情页。回到根视图,也只是清空 path(09:40)。
三列结构:让选择状态驱动 split view
(10:40)第二个配方是 Mail 或 Notes 这种多列结构。第一列选分类,第二列选食谱,第三列显示详情。
这里的重点是 NavigationSplitView 和 List(selection:) 的配合。值驱动 NavigationLink 出现在带 selection 的 List 里时,点击 link 会自动更新对应的 selection。
struct MultipleColumns: View {
@State private var selectedCategory: Category?
@State private var selectedRecipe: Recipe?
@StateObject private var dataModel = DataModel()
var body: some View {
NavigationSplitView {
List(Category.allCases, selection: $selectedCategory) { category in
NavigationLink(category.localizedName, value: category)
}
.navigationTitle("Categories")
} content: {
List(
dataModel.recipes(in: selectedCategory),
selection: $selectedRecipe)
{ recipe in
NavigationLink(recipe.name, value: recipe)
}
.navigationTitle(selectedCategory?.localizedName ?? "Recipes")
} detail: {
RecipeDetail(recipe: selectedRecipe)
}
}
}
关键点:
selectedCategory记录 sidebar 当前选中的分类。selectedRecipe记录 content 列当前选中的食谱。NavigationSplitView { ... } content: { ... } detail: { ... }定义三列结构。List(Category.allCases, selection: $selectedCategory)让第一列的选择状态可读写。NavigationLink(category.localizedName, value: category)提交Category值;因为 List 的 selection 类型匹配,SwiftUI 会更新selectedCategory。- 第二个 List 用
dataModel.recipes(in: selectedCategory)根据第一列选择展示食谱。 RecipeDetail(recipe: selectedRecipe)用第二列选择更新详情区域。
这个结构在大屏上减少模态切换。用户可以同时看见分类、列表和详情。Session 在 13:00 说明,NavigationSplitView 会在 iPhone 或 iPad Slide Over 中自动适配成单列 stack,selection 的变化会转换成对应的 push 和 pop。
Split view 加 stack:在详情列里继续 push
(14:10)第三个配方把两种容器组合起来。左侧仍然是分类列表,右侧详情区先显示食谱网格。点击网格里的食谱后,在右侧详情区内部 push 到食谱详情;点击相关食谱,还能继续 push。
代码把 NavigationStack 放进 NavigationSplitView 的 detail 列。
struct MultipleColumnsWithStack: View {
@State private var selectedCategory: Category?
@State private var path: [Recipe] = []
@StateObject private var dataModel = DataModel()
var body: some View {
NavigationSplitView {
List(Category.allCases, selection: $selectedCategory) { category in
NavigationLink(category.localizedName, value: category)
}
.navigationTitle("Categories")
} detail: {
NavigationStack(path: $path) {
RecipeGrid(category: selectedCategory)
}
}
.environmentObject(dataModel)
}
}
关键点:
selectedCategory控制左侧分类选择。path: [Recipe]控制右侧详情列内部的 push 栈。NavigationSplitView负责宽屏上的分栏结构。NavigationStack(path: $path)嵌在 detail 列里,只管理右侧的局部栈。RecipeGrid(category: selectedCategory)是这个局部栈的根视图。
RecipeGrid 里还有一个容易踩坑的点:navigationDestination 不应该放在 LazyVGrid 里的每个 link 上。
struct RecipeGrid: View {
@EnvironmentObject private var dataModel: DataModel
var category: Category?
var body: some View {
if let category = category {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(dataModel.recipes(in: category)) { recipe in
NavigationLink(value: recipe) {
RecipeTile(recipe: recipe)
}
}
}
}
.navigationTitle(category.localizedName)
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
} else {
Text("Select a category")
}
}
var columns: [GridItem] { [GridItem(.adaptive(minimum: 240))] }
}
关键点:
LazyVGrid只在需要时加载子视图。Session 在 16:19 明确说,List、Table、LazyVGrid这类 lazy 容器不会立刻加载全部内容。- 如果把
navigationDestination放进 lazy 子视图,外层NavigationStack可能看不到它。 - 把
.navigationDestination(for: Recipe.self)放在ScrollView上,既靠近 link,又能保证 destination 被 stack 发现。 NavigationLink(value: recipe)和 destination 的类型都是Recipe,类型匹配后才会 pushRecipeDetail。
这个组合适合 Photos 风格的 iPad 或 Mac 界面。根层级用分栏降低层级深度,详情列内部再用 stack 展示连续 drill-down。
状态恢复:保存导航模型,避免复制整个数据模型
(18:12)最后一个配方解决状态恢复。用户离开 App 再回来时,应该回到原来的分类、网格和详情页。
Session 给出的做法分三步:把导航状态封装成模型,让模型支持 Codable,再用 SceneStorage 保存 JSON data。
struct UseSceneStorage: View {
@StateObject private var navModel = NavigationModel()
@SceneStorage("navigation") private var data: Data?
@StateObject private var dataModel = DataModel()
var body: some View {
NavigationSplitView {
List(
Category.allCases, selection: $navModel.selectedCategory
) { category in
NavigationLink(category.localizedName, value: category)
}
.navigationTitle("Categories")
} detail: {
NavigationStack(path: $navModel.recipePath) {
RecipeGrid(category: navModel.selectedCategory)
}
}
.task {
if let data = data {
navModel.jsonData = data
}
for await _ in navModel.objectWillChangeSequence {
data = navModel.jsonData
}
}
.environmentObject(dataModel)
}
}
关键点:
navModel集中保存selectedCategory和recipePath,避免两个状态恢复到不一致的组合。@SceneStorage("navigation") private var data: Data?把每个 scene 的导航状态交给 SwiftUI 保存。.task在 view 出现时运行。已有 data 时,先把 data 解码回navModel。for await _ in navModel.objectWillChangeSequence监听导航模型变化。- 每次变化后,
data = navModel.jsonData把最新导航状态写回 scene storage。
NavigationModel 的编码部分很关键。它没有保存整个 Recipe,而是保存食谱 ID。
class NavigationModel: ObservableObject, Codable {
@Published var selectedCategory: Category?
@Published var recipePath: [Recipe] = []
enum CodingKeys: String, CodingKey {
case selectedCategory
case recipePathIds
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory)
try container.encode(recipePath.map(\.id), forKey: .recipePathIds)
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.selectedCategory = try container.decodeIfPresent(
Category.self, forKey: .selectedCategory)
let recipePathIds = try container.decode([Recipe.ID].self, forKey: .recipePathIds)
self.recipePath = recipePathIds.compactMap { DataModel.shared[$0] }
}
}
关键点:
selectedCategory可以直接编码,因为Category是稳定的小枚举。recipePath.map(\.id)只保存 path 上每个食谱的 ID。- 解码时先还原 ID 列表,再用共享数据模型查回
Recipe。 compactMap会丢弃找不到的食谱。Session 在 21:28 举例:如果同步后另一台设备删除了食谱,恢复状态时就不能假设每个 ID 都还存在。- 这样保存的是导航意图,避免把可能变化的模型内容复制到本地导航状态里。
核心启发
-
做什么:给内容型 App 加 URL 深链接到具体条目。
为什么值得做:NavigationStack的 path 本身就是可变集合,深链接可以直接转成 path 值。
怎么开始:解析 URL 得到模型 ID,查出模型后写入[Recipe]、NavigationPath或自己的 path 类型。 -
做什么:把 iPad 详情页改成 sidebar + grid + detail 的组合。
为什么值得做:NavigationSplitView能在大屏展示多列,在窄屏自动适配成单列 stack。
怎么开始:用List(selection:)管理 sidebar 选择,在 detail 列嵌入NavigationStack(path:)。 -
做什么:恢复用户离开 App 前所在的详情层级。
为什么值得做:Session 展示了SceneStorage保存导航 JSON 的完整路径。
怎么开始:把 selection 和 path 封装进ObservableObject & Codable模型,只编码稳定 ID,再用SceneStorage保存Data。 -
做什么:清理旧的布尔绑定式程序化导航。
为什么值得做:Session 在 24:24 说明,旧式带 binding 的 programmatic links 从 iOS 16 及对应系统开始废弃。
怎么开始:把多个isActive或tag/selectionlink 重构成值驱动 link,再集中声明navigationDestination。
关联 Session
- What’s new in SwiftUI — 查看 WWDC 2022 SwiftUI 的整体更新,包含导航、布局、图表和分享等方向。
- SwiftUI on iPad: Organize your interface — 深入 iPad 分栏、列表选择和界面组织方式,补充本 Session 提到的 split view 配置。
- Bring multiple windows to your SwiftUI app — 继续学习 SwiftUI scene、窗口和状态恢复,适合和导航状态持久化一起看。
- Compose custom layouts with SwiftUI — 了解 Grid 与 Layout 协议,和本文中的
LazyVGrid食谱网格形成配套。
评论
GitHub Issues · utterances