WWDC Quick Look 💓 By SwiftGGTeam
The SwiftUI cookbook for navigation

The SwiftUI cookbook for navigation

观看原视频

Highlight

SwiftUI 在 iOS 16 和 macOS Ventura 中新增 NavigationStackNavigationSplitView、值驱动 NavigationLinknavigationDestination 和可持久化导航状态,让开发者可以用数据而非分散的布尔绑定控制 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 组合起来,并用 CodableSceneStorage 保存导航状态。

详细内容

单列栈:把 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 这种多列结构。第一列选分类,第二列选食谱,第三列显示详情。

这里的重点是 NavigationSplitViewList(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 明确说,ListTableLazyVGrid 这类 lazy 容器不会立刻加载全部内容。
  • 如果把 navigationDestination 放进 lazy 子视图,外层 NavigationStack 可能看不到它。
  • .navigationDestination(for: Recipe.self) 放在 ScrollView 上,既靠近 link,又能保证 destination 被 stack 发现。
  • NavigationLink(value: recipe) 和 destination 的类型都是 Recipe,类型匹配后才会 push RecipeDetail

这个组合适合 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 集中保存 selectedCategoryrecipePath,避免两个状态恢复到不一致的组合。
  • @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 及对应系统开始废弃。
    怎么开始:把多个 isActivetag/selection link 重构成值驱动 link,再集中声明 navigationDestination

关联 Session

评论

GitHub Issues · utterances