大多数浏览器和
Developer App 均支持流媒体播放。
-
SwiftUI 导航开发指南
构建一款出色的 App 要从清晰而稳健的导航结构开始。加入我们著名的“编程厨房”,跟随 SwiftUI 团队学习如何为您的 App 打造一流的体验。我们将介绍 SwiftUI 的导航叠放和分屏浏览功能,说明如何链接到 App 的特定区域,以及探索如何快速而轻松地恢复导航状态。
资源
- Bringing robust navigation structure to your SwiftUI app
- List
- Migrating to new navigation types
- NavigationSplitView
- NavigationStack
相关视频
Tech Talks
WWDC22
-
下载
♪ 柔和乐器演奏的嘻哈音乐 ♪ ♪ 您好 我是 SwiftUI 团队的工程师 Curt SwiftUI 中有一些有用的 新 API 能够进行导航 我很喜欢用 这些新 API 构建 App 很高兴能够与您分享 这些 API 从基本堆栈进行扩展 例如从 Apple TV, iPhone 和 Apple Watch 到强大的多栏演示 新的 API 能为程序导航和深度链接 提供强大的支持 让您能够为您的 App 制作各种 完美体系所需的组件 这节内容会给大家提供一份大餐: 如何用 SwiftUI 导航 制作 App 的技巧 如果您已经在使用 SwiftUI 希望这些新的 API 能助您更上一层楼 首先讲的是 进入新的数据驱动 导航 API 所需的食材 然后 会讲到我们的品尝菜单: 几个简单快捷的食谱 用于对导航进行完全的程序化控制 甜点课程将分享一些技巧 关于如何用新的 API 在 App 中维持导航状态 如果您已经 用 SwiftUI 导航了 您可能想知道 新的 API 有何不同 那在深入研究之前 我们先回顾一下现有的一些 API 现有 API 基于发送 显示在其他列 或堆栈视图的链接 例如 如果我有一个列表 罗列了根视图中的导航链接 点击其中一个链接时 会把视图推送到堆栈上 这对基本导航很适用 您也可以继续使用这个模式 不过我们还是说回根视图 使用现有的导航 API 来以编程方式呈现链接 我会向链接添加绑定 例如 我可以展示这个链接的视图 做法就是 将 item.showDetail 设为真 但这意味着我要为每个链接单独绑定 使用新的 API 我们可以在 绑定整个容器 称为 NavigationStack 这里的路径是一个集合 表示推送至堆栈的所有值 NavigationLinks 将值附加到路径 可以通过改变路径进行深度链接 或从路径删除所有项目 从而弹出到根视图 本节中 我会向您展示新的导航 API 如何实现数据驱动的程序化导航 希望您也发现它有多简单好用 在讲新导航 API 的适用方法前 我想先分享一下菜单上都有哪些内容 我最近喜欢上做饭了 所以在开发一个记录食谱的 App 我有很多关于 如何呈现此信息的想法 例如 这是三列呈现 第一列可以选择食谱类别 选择好类别后 第二列会列出我收集的食谱 选择了一个食谱后 详情区会显示该食谱的材料 详情区也有链接 通往相关食谱 我奶奶总是说: “馅饼的诀窍在面皮” 所以这就是我们今天要做的 我们的食材就是新的导航 API 我们会深入探讨 然后看一些具体的导航食谱 来将食材结合起来 新的导航 API 引入了几个 可以使用的新容器类型 帮助描述您的 App 结构 还有全新的 NavigationLink 品种 帮助顾客在该结构中到处浏览 第一个新容器是 NavigationStack NavigationStack 表征 一个 push-pop 接口 像 Apple Watch 上的“查找” iPhone 上的“设置” 以及 macOS Ventura 上 新 “系统设置” 第二个新的容器类型 是 NavigationSplitView NavigationSplitView 非常适合多列 App 像 Mac 和 iPad 上的 Mail 或 Notes 而且 NavigationSplitView 会自动适配 iPhone 上的单列堆栈 iPad 上的侧拉 甚至是 Apple Watch 和 Apple TV NavigationSplitView 有两组初始器 一组像这个 会创建一个两列界面 另一组初始器 创建的是三列界面 NavigationSplitView 带有一个购物车 有配置选项可以自定义列宽 侧边栏展示 甚至以编程方式显示和隐藏列 这里我不细讲配置选项了 不过可以看看 我的同事 Raj 的课程 《iPad 上 SwiftUI:视图组织》 还有很棒的有关如何 调整 NavigationSplitView 至 最方便实用的技巧 以前 NavigationLinks 总是 要呈现标题和视图 新品种仍然有标题 但不呈现试图 而是呈现值 例如 这个链接呈现的 是苹果派的食谱 下面我们会看到 NavigationLink 很聪明 一个链接的行为取决于 其中的 NavigationStack 或列表 要了解这些 美味的新 API 如何协同工作 我们看看使用的具体食谱 在我的食谱 App 和您的 App 中 我们的第一个食谱 是一个基本的视图堆栈 就像 Apple Watch 上的“查找” 或 iPhone 上的“设置”一样 我为每个类别设置了分区 一个分区里可以点击一个食谱 来查看详细信息 每个食谱里 还可以点击 其中一个相关食谱 将其推送至堆栈中 可以使用“返回”按钮返回原始食谱 然后回到类别列表 这个食谱结合了 NavigationStack 和新的 NavigationLink 以及一个导航目标修改器 我们看看它们如何运作 首先从一个基本的 NavigationStack 开始 这里面有一个罗列所有类别的列表 和一个导航标题 列表中每个类别都有一个分区 接下来每个分区中 我将为该类别的每个食谱 添加一个 NavigationLink 现在我先让链接显示 我的 RecipeDetail 视图 这使用了现有的 查看目标 NavigationLink 这样就可以让这个导航界面 顺利工作了 不过程序化导航呢? 要添加程序化导航 我要先梳理开这个 导航链接的两个部分 一是它呈现的值 二是与该值对应的视图 做法如下 首先 我从链接中调出目标视图 进入新的导航目标修改器 此修改器会显示它所负责呈现 数据的类型 这里是一个食谱 修改器所采用的视图构建器 会描述出现一个食谱值后 推送至堆栈的视图 然后 我会切换到一个 新的 NavigationLinks 然后输入食谱值 现在我们看看后台 NavigationStack 是如何工作的 每个导航堆栈都记录 堆栈显示的所有数据的路径 当堆栈只是显示它的根视图时 比如这里 路径为空 接下来 堆栈还会记录 堆栈内或推送至堆栈内的 所有导航目标 总的来说 这是一套操作 不过在这个例子中 我们只有一个目标 我们把推送视图也添加到图表中吧 现在因为路径是空的 所以推送的视图列表也是空的 现在 就像牛奶和饼干一样 把它们加在一起后 奇迹就发生了 点击一个呈现值的链接后 该值就被附加到路径中了 然后 导航堆栈会将目标映射到 路径值上 以决定 哪些视图会被推送至堆栈 现在 在我的苹果派食谱中点击派皮 该链接也会将其附加到路径中 NavigationStack 的魔术生效了 又一个 RecipeDetail 视图 被推送至堆栈了 我每添加一个值到路径上 NavigationStack 就会推送一个视图 点击“返回”后 NavigationStack 会删除路径中 以及推送视图中的最后一项 NavigationStack 还有一个技巧 可以让我们利用绑定来访问此路径 我们看回代码 我们刚刚在这里 要绑定路径 首先我要添加一些 State 因为推送至这个堆栈的 每个值都是一个食谱 所以我可以用一系列食谱作为路径 如果需要在堆栈上呈现各种数据 一定要看看 能擦除类型的 新 NavigationPath 集合 有了路径状态以后 我会添加一个参数 到我的 NavigationStack 上 并将绑定信息传送至路径 然后我的堆栈就快出炉了 例如 我可以添加一个 跳转到特定食谱的方法 或者 只需重置路径 我就可以从堆栈的 任何位置跳转回根目录 这就是用 新 SwiftUI 的 NavigationStack 能呈现值的 NavigationLinks 以及 navigationDestinations 来制作可推送堆栈的方法 这个食谱 适用于所有平台 包括 Mac 但在 iPhone Apple TV 和 Apple Watch 上尤其好用 想看 NavigationStack 的实操 敬请收看 《为 Apple Watch 创建一个生产力 App》 我们的下一个食谱是 无堆栈的多栏演示 就像 Mac 和 iPad 上的 Mail 那样 在 iPad 上 边栏最初是隐藏的 我可以把它调出来并选择一个类别 然后在第二列中 我可以选择一个食谱 第三列显示食谱详细信息 这个食谱结合使用了 新型 NavigationLink 的 NavigationSplitView 和一个“列表”选项 这个食谱在大型设备上很好用 因为它有助于避免模态 我可以看到所有信息 而无需深入 下面我们演示一下 先从三列 NavigationSplitView 开始 这里有内容和细节的占位视图 然后 在边栏中添加一个列表 罗列出所有的类别 和一个导航标题 在列表中 每个类别都有 一个 NavigationLink 接下来 我会添加一些 State 以记录我所选择的类别 在边栏中调整列表 以使用 selectedCategory 注意这里我们将绑定信息 发送到了已选类别中 这样可以让列表及其内容 对选择进行操纵 将呈现值的链接加入 已选类别的相应列表后 类别在这里 链接将自动 在点击后更新选择 所以现在 我在边栏中选择了类别后 SwiftUI 就会 更新 selectedCategory 关于选择及列表的更多信息 我推荐大家去看 前面说的 Raj 的 《视图组织》课程 接下来 我把内容栏中的 占位符换成 已选类别的食谱列表 并为此列添加一个导航标题 跟设置 selectedCategory 一样 我也可以用相同的技术 记录内容列表中选定的食谱 我会为 selectedRecipe 设定 State 让我的内容列表使用该状态 并为每个食谱设定值呈现链接 最后 我会更新详情列 以显示 selectedRecipe 的详细信息 设置好后 我就又能实现对导航 完全的编程控制了 例如 要导航到我当天的食谱 只需更新选择状态即可 这就是使用新 SwiftUI 中 NavigationSplitView 能呈现值的 NavigationLinks 及带选择功能的列表 来制作多列视图的方法 这样将列表选择 与 NavigationSplitView 结合 有个很酷的好处 就是能让 SwiftUI 自动适配 iPhone 上 单个堆栈的分屏浏览 或是 iPad 上的侧拉 更改选择会自动修改 iPhone 上的推送和弹出 当然 这个多栏演示 在 Mac 上也很好用 虽然 Apple TV 和 Apple Watch 不支持多列显示 不过这些平台 也可以自动转化为单列堆栈 SwiftUI 中的 NavigationSplitView 适用于所有平台 接下来 我们再看看如何 通过构建两列导航界面 将全部材料结合使用 就像 iPad 和 Mac 上的 “照片” 功能一样 选择一个类别后 详情区会显示一个网格 罗列该类别中所有食谱 点击一个食谱 就推送至 详情区的堆栈中 点击相关食谱也推送至堆栈 我可以随时回到这个食谱列表中
这个食谱就是我们的拿手好菜 结合使用了导航分屏浏览 堆栈 链接 目标和列表 现在看看这些材料 是如何结合在一起的吧 先从两列 NavigationSplitView 开始 第一列与之前的食谱完全相同 有一些 State 来记录 selectedCategory 和一个绑定该状态的列表 以及一个呈现值的 NavigationLink 和必要的 navigationTitle 这个食谱的不同之处在于详情区 新的导航 API 真正利用了组合 就像我可以把一个列表加入 NavigationSplitView 的列内一样 我也可以在列中 加入 NavigationStack 此 NavigationStack 的根视图 就是我的 RecipeGrid 注意 RecipeGrid 位于 NavigationStack 内 也就是说我可以 在 RecipeGrid 内 放堆栈相关的修改器 我们进一步看看 RecipeGrid 的组成 您就明白是什么意思了 RecipeGrid 是一个视图 以类别为参数 因为类别在这里是可选的 所以我先从 if-let 开始 else 则用来处理 选择为空的事件 我的 if 中会添加一个 滚动视图和惰性网格 惰性网格布局采用一系列视图 这里我会用 ForEach 来迭代我的食谱 每个食谱 都有一个呈现值的 NavigationLink 该链接显示一个食谱值 在这个尾随闭包中 链接的标签 是带有缩略图 和标题的 RecipeTile 那么 要完成这个网格还要做什么? 哦 我还没有告诉 NavigationStack 如何从食谱映射到详情视图呢 我在第一个食谱中提到过 新的 NavigationStack 使用 navigationDestination 修改器 来将其路径上的值 映射到堆栈所要显示的视图 所以我们要添加一个 navigationDestination 修改器 不过加在哪里呢? 我很想把它直接加到链接上 但这样不行 原因有两个 像列表 表格 或在这里的 LazyVGrid 一样 惰性容器不能立即加载所有视图 如果我把修改器加在这里 目标就可能无法加载 所以周围的 NavigationStack 就可能看不到 其次 如果我把修改器放在这里 网格中的每个项目都会重复一次 相反 我会将修改器 加到我的 ScrollView 中 把修改器 加到 ScrollView 外面 可以确保 NavigationStack 无论滚动到哪里 都能看到 这个 navigationDestination 将修改器放在这里的另一个好处是 这样跟其对应的链接依然很近 导航目标让我可以灵活地组织代码 让我或我的团队更容易接受 回到我的 NavigationSplitView 要实现完整的程序化导航 还有最后一步 就是添加导航路径 我会添加 State 来保存路径 并将状态绑定到 我的 NavigationStack 上 有了完整的程序化导航 我就可以在这个导航界面 写一个方法来展示我当天的食谱 就是使用 SwiftUI 的 新 NavigationSplitView NavigationStack 和可呈现值的 NavigationLinks 以及带可选项的列表 来制作带堆栈的 多列导航界面的方法 和上一个食谱一样 这个食谱也能 自动适配窄屏展示 并适用于所有平台 这些食谱很有趣 尤其是对 构建 App 导航而言 不过我们的导航盛宴 不能少了甜品 为此 我们看看如何保持导航状态 要在 App 中保持导航状态 只需另外两种食材 Codable 和 SceneStorage 这个食谱有三个基本步骤 首先 我要将导航状态 封存在 NavigationModel 类型中 这样就能将它作为 一个单元来保存和恢复 这样就能始终保持一致了 然后 将我的导航模型 设为 Codable 最后 用 SceneStorage 来 保存和恢复我的模型 在此过程中我要小心 避免我的 App 像 做坏了的蛋奶酥一样垮掉 不过步骤很简单 先看第一步 这是我们上一个食谱末尾的代码 我的导航状态 存储在 selectedCategory 和路径属性中 selectedCategory 会记录 边栏中的选择 路径则记录详情区中 推送到堆栈上的视图 我会添加一个 新的 NavigationModel 类 使其与 ObservableObject 相符 接下来 将导航状态移入模型对象 将属性包装从 State 更改为 Published 然后 我再添加 一个 StateObject 来保存我的 NavigationModel 实例 并更改参数来使用新的模型对象 再下来 将导航模型 设为 Codable 先将 Codable 规则 添加到类 很多时候 Swift 可以自动 生成 Codable 规则 但这里我想自己设置规则 主要是因为 Recipe 是一个模型值 我不想为状态恢复而 存储整个模型值 原因有两个 一、我的食谱数据库 已经包含了食谱的所有详细信息 重复已存储的导航状态信息 存储效率不高 二、如果我的食谱数据库可以独立于 本地导航状态 自由更改 比如因为我总算要添加同步了 就不希望本地导航状态里有旧数据 要自定义可编码性 接下来我会添加 CodingKeys 一个键就是 selectedCategory 不过注意我把另一个 命名为“recipePathIds” 我打算只存储 路径上食谱的标识符 我的编码方法是创建一个键控容器 使用我的编码键并把所选类别 添加到容器中 我用了 encodeIfPresent 这样就只要写入非零 nil 然后 添加食谱路径标识符 注意这里是在映射路径 来获取要编码的标识符 例如 假设我的导航状态中 选定类别中有甜点 而路径上则有苹果派和派皮 像上面绿色框中这样 这可能会被编码为 JSON 如另一个框中所示 要完成可编码性设置 要添加所需的初始化程序 这里一个有趣的点 就是我要解码食谱 ID 然后用我的共享数据模型 将 ID 转换回食谱 我会用 compactMap 丢弃任何 找不到的食谱 例如 如果开了同步 并删除了 另一台设备上的食谱 这也是我迟早会干的一件事 这时候您就要谨慎了 您要确保自己的 App 中 恢复的导航状态 始终有意义 最后 添加一个计算属性 用于作为 JSON 数据 来读写模型 现在 一个导航模型做好了 这个模型可以自行编码和解码 剩下的就是保存和恢复了 为此 我会用 SceneStorage 这里就要离开主视图了 之前我们是用 StateObject 来保存 NavigationModel 的 现在 我要添加 一些 SceneStorage 来维持我的 NavigationModel SceneStorage 属性 可以自动保存 和恢复其相关值 存储类型可选时 像我这里的数据 创建新场景时该值为 nil 系统在恢复场景时 SwiftUI 会确保 SceneStorage 属性的值 也同样会恢复 我会利用这一点来维护 我的 NavigationModel 为此 我会在我的视图中 添加一个任务修改器 这个修改器会异步运行闭包 视图出现时就会开始 视图消失时就会取消 每次我的视图一出现 我会首先检查 是否有以前运行时残留的数据 有的话我会用该数据来更新导航模型 然后 我会启动 一个异步的 for 循环 让导航模型每次发生 变化时都进行一次迭代 这个循环每次更改时都会运行 所以我可以用它来将导航状态 保存回场景存储数据 好了! 如果我要离开 跑去 网上看经典 Julia Child 烹饪节目 App 会记住我离开的位置 再次打开 App 时 会直接回到之前的状态 不过呢 一本完整的食谱 不能少了最后一个古怪环节 即各种方便的厨房技巧 虽然我找不出三种香菜替代品 不过一些导航小贴士却是有的 尽快换新 NavigationStack 和 NavigationSplitView 如果您用的是 堆栈样式的 NavigationView 就换成 NavigationStack NavigationStack 在 Apple TV 上 也是不错的首选 Apple Watch 或 iPad 和 iPhone 上的工作表 它们向来默认使用堆栈样式 如果您用的是 多列 NavigationView 那就换成 NavigationSplitView 如果您已经在用程序化导航了 用的是带有绑定的链接 那我强烈建议您换成 新的值呈现 NavigationLink 加上导航路径和列表选择 旧式程序链接从 iOS 16 及 对应版本开始就已经弃用了 想了解如何换用 新 API 的详情和示例 可以去看 开发人员笔记中的 《转换新的导航类型》 接下来 要记住 列表 新的 NavigationSplitView 和 NavigationStack 是 设计来配合使用的 把它们组合起来 才能创建 您的顾客喜欢的导航界面 使用导航堆栈时 导航目标 可以是堆栈或其子视图内的任何位置 可以考虑将目标放在相应链接附近 这样更方便维护 但不要将它们放在惰性容器中 最后 我建议 开始使用 NavigationSplitView 来创建 您专属的导航界面吧 哪怕您本来是 为 iPhone 开发的 NavigationSplitView 也会自动 适配窄屏设备 如果您想 支持横向 iPhone Pro Max 或在 iPad 或 Mac 上安装 App NavigationSplitView 则能充分利用所有的空间 谢谢 很高兴有机会 与您分享新 SwiftUI 导航 API! 除了之前提到的课程 我还想请您关注 《让您的 SwiftUI App 支持多窗口展示》 里面有关于如何在 App 中 打开新窗口和场景的 一些重要信息 希望这些导航食谱 在我们的食谱 App 中 让您胃口大开 期待看到您烹制出 自己的精彩 App 体验 祝您好胃口! ♪
-
-
6:05 - Pushable Stack
import SwiftUI // Pushable stack 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) } } // Helpers for code example struct RecipeDetail: View { @EnvironmentObject private var dataModel: DataModel var recipe: Recipe var body: some View { Text("Recipe details go here") .navigationTitle(recipe.name) ForEach(recipe.related.compactMap { dataModel[$0] }) { related in NavigationLink(related.name, value: related) } } } class DataModel: ObservableObject { @Published var recipes: [Recipe] = builtInRecipes func recipes(in category: Category?) -> [Recipe] { recipes .filter { $0.category == category } .sorted { $0.name < $1.name } } subscript(recipeId: Recipe.ID) -> Recipe? { // A real app would want to maintain an index from identifiers to // recipes. recipes.first { recipe in recipe.id == recipeId } } } enum Category: Int, Hashable, CaseIterable, Identifiable, Codable { case dessert case pancake case salad case sandwich var id: Int { rawValue } var localizedName: LocalizedStringKey { switch self { case .dessert: return "Dessert" case .pancake: return "Pancake" case .salad: return "Salad" case .sandwich: return "Sandwich" } } } struct Recipe: Hashable, Identifiable { let id = UUID() var name: String var category: Category var ingredients: [Ingredient] var related: [Recipe.ID] = [] var imageName: String? = nil } struct Ingredient: Hashable, Identifiable { let id = UUID() var description: String static func fromLines(_ lines: String) -> [Ingredient] { lines.split(separator: "\n", omittingEmptySubsequences: true) .map { Ingredient(description: String($0)) } } } let builtInRecipes: [Recipe] = { var recipes = [ "Apple Pie": Recipe( name: "Apple Pie", category: .dessert, ingredients: Ingredient.fromLines(applePie)), "Baklava": Recipe( name: "Baklava", category: .dessert, ingredients: []), "Bolo de Rolo": Recipe( name: "Bolo de rolo", category: .dessert, ingredients: []), "Chocolate Crackles": Recipe( name: "Chocolate crackles", category: .dessert, ingredients: []), "Crème Brûlée": Recipe( name: "Crème brûlée", category: .dessert, ingredients: []), "Fruit Pie Filling": Recipe( name: "Fruit Pie Filling", category: .dessert, ingredients: []), "Kanom Thong Ek": Recipe( name: "Kanom Thong Ek", category: .dessert, ingredients: []), "Mochi": Recipe( name: "Mochi", category: .dessert, ingredients: []), "Marzipan": Recipe( name: "Marzipan", category: .dessert, ingredients: []), "Pie Crust": Recipe( name: "Pie Crust", category: .dessert, ingredients: Ingredient.fromLines(pieCrust)), "Shortbread Biscuits": Recipe( name: "Shortbread Biscuits", category: .dessert, ingredients: []), "Tiramisu": Recipe( name: "Tiramisu", category: .dessert, ingredients: []), "Crêpe": Recipe( name: "Crêpe", category: .pancake, ingredients: []), "Jianbing": Recipe( name: "Jianbing", category: .pancake, ingredients: []), "American": Recipe( name: "American", category: .pancake, ingredients: []), "Dosa": Recipe( name: "Dosa", category: .pancake, ingredients: []), "Injera": Recipe( name: "Injera", category: .pancake, ingredients: []), "Acar": Recipe( name: "Acar", category: .salad, ingredients: []), "Ambrosia": Recipe( name: "Ambrosia", category: .salad, ingredients: []), "Bok l'hong": Recipe( name: "Bok l'hong", category: .salad, ingredients: []), "Caprese": Recipe( name: "Caprese", category: .salad, ingredients: []), "Ceviche": Recipe( name: "Ceviche", category: .salad, ingredients: []), "Çoban salatası": Recipe( name: "Çoban salatası", category: .salad, ingredients: []), "Fiambre": Recipe( name: "Fiambre", category: .salad, ingredients: []), "Kachumbari": Recipe( name: "Kachumbari", category: .salad, ingredients: []), "Niçoise": Recipe( name: "Niçoise", category: .salad, ingredients: []), ] recipes["Apple Pie"]!.related = [ recipes["Pie Crust"]!.id, recipes["Fruit Pie Filling"]!.id, ] recipes["Pie Crust"]!.related = [recipes["Fruit Pie Filling"]!.id] recipes["Fruit Pie Filling"]!.related = [recipes["Pie Crust"]!.id] return Array(recipes.values) }() let applePie = """ ¾ cup white sugar 2 tablespoons all-purpose flour ½ teaspoon ground cinnamon ¼ teaspoon ground nutmeg ½ teaspoon lemon zest 7 cups thinly sliced apples 2 teaspoons lemon juice 1 tablespoon butter 1 recipe pastry for a 9 inch double crust pie 4 tablespoons milk """ let pieCrust = """ 2 ½ cups all purpose flour 1 Tbsp. powdered sugar 1 tsp. sea salt ½ cup shortening ½ cup butter (Cold, Cut Into Small Pieces) ⅓ cup cold water (Plus More As Needed) """ struct PushableStack_Previews: PreviewProvider { static var previews: some View { PushableStack() } }
-
10:40 - Multiple Columns
import SwiftUI // Multiple columns 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) } } } // Helpers for code example struct RecipeDetail: View { var recipe: Recipe? var body: some View { Text("Recipe details go here") .navigationTitle(recipe?.name ?? "") } } class DataModel: ObservableObject { @Published var recipes: [Recipe] = builtInRecipes func recipes(in category: Category?) -> [Recipe] { recipes .filter { $0.category == category } .sorted { $0.name < $1.name } } } enum Category: Int, Hashable, CaseIterable, Identifiable, Codable { case dessert case pancake case salad case sandwich var id: Int { rawValue } var localizedName: LocalizedStringKey { switch self { case .dessert: return "Dessert" case .pancake: return "Pancake" case .salad: return "Salad" case .sandwich: return "Sandwich" } } } struct Recipe: Hashable, Identifiable { let id = UUID() var name: String var category: Category var ingredients: [Ingredient] var related: [Recipe.ID] = [] var imageName: String? = nil } struct Ingredient: Hashable, Identifiable { let id = UUID() var description: String static func fromLines(_ lines: String) -> [Ingredient] { lines.split(separator: "\n", omittingEmptySubsequences: true) .map { Ingredient(description: String($0)) } } } let builtInRecipes: [Recipe] = { var recipes = [ "Apple Pie": Recipe( name: "Apple Pie", category: .dessert, ingredients: Ingredient.fromLines(applePie)), "Baklava": Recipe( name: "Baklava", category: .dessert, ingredients: []), "Bolo de Rolo": Recipe( name: "Bolo de rolo", category: .dessert, ingredients: []), "Chocolate Crackles": Recipe( name: "Chocolate crackles", category: .dessert, ingredients: []), "Crème Brûlée": Recipe( name: "Crème brûlée", category: .dessert, ingredients: []), "Fruit Pie Filling": Recipe( name: "Fruit Pie Filling", category: .dessert, ingredients: []), "Kanom Thong Ek": Recipe( name: "Kanom Thong Ek", category: .dessert, ingredients: []), "Mochi": Recipe( name: "Mochi", category: .dessert, ingredients: []), "Marzipan": Recipe( name: "Marzipan", category: .dessert, ingredients: []), "Pie Crust": Recipe( name: "Pie Crust", category: .dessert, ingredients: Ingredient.fromLines(pieCrust)), "Shortbread Biscuits": Recipe( name: "Shortbread Biscuits", category: .dessert, ingredients: []), "Tiramisu": Recipe( name: "Tiramisu", category: .dessert, ingredients: []), "Crêpe": Recipe( name: "Crêpe", category: .pancake, ingredients: []), "Jianbing": Recipe( name: "Jianbing", category: .pancake, ingredients: []), "American": Recipe( name: "American", category: .pancake, ingredients: []), "Dosa": Recipe( name: "Dosa", category: .pancake, ingredients: []), "Injera": Recipe( name: "Injera", category: .pancake, ingredients: []), "Acar": Recipe( name: "Acar", category: .salad, ingredients: []), "Ambrosia": Recipe( name: "Ambrosia", category: .salad, ingredients: []), "Bok l'hong": Recipe( name: "Bok l'hong", category: .salad, ingredients: []), "Caprese": Recipe( name: "Caprese", category: .salad, ingredients: []), "Ceviche": Recipe( name: "Ceviche", category: .salad, ingredients: []), "Çoban salatası": Recipe( name: "Çoban salatası", category: .salad, ingredients: []), "Fiambre": Recipe( name: "Fiambre", category: .salad, ingredients: []), "Kachumbari": Recipe( name: "Kachumbari", category: .salad, ingredients: []), "Niçoise": Recipe( name: "Niçoise", category: .salad, ingredients: []), ] recipes["Apple Pie"]!.related = [ recipes["Pie Crust"]!.id, recipes["Fruit Pie Filling"]!.id, ] recipes["Pie Crust"]!.related = [recipes["Fruit Pie Filling"]!.id] recipes["Fruit Pie Filling"]!.related = [recipes["Pie Crust"]!.id] return Array(recipes.values) }() let applePie = """ ¾ cup white sugar 2 tablespoons all-purpose flour ½ teaspoon ground cinnamon ¼ teaspoon ground nutmeg ½ teaspoon lemon zest 7 cups thinly sliced apples 2 teaspoons lemon juice 1 tablespoon butter 1 recipe pastry for a 9 inch double crust pie 4 tablespoons milk """ let pieCrust = """ 2 ½ cups all purpose flour 1 Tbsp. powdered sugar 1 tsp. sea salt ½ cup shortening ½ cup butter (Cold, Cut Into Small Pieces) ⅓ cup cold water (Plus More As Needed) """ struct MultipleColumns_Previews: PreviewProvider { static var previews: some View { MultipleColumns() } }
-
14:10 - Multiple Columns with a Stack
import SwiftUI // Multiple columns with a stack 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) } } 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))] } } struct RecipeDetail: View { @EnvironmentObject private var dataModel: DataModel var recipe: Recipe var body: some View { Text("Recipe details go here") .navigationTitle(recipe.name) ForEach(recipe.related.compactMap { dataModel[$0] }) { related in NavigationLink(related.name, value: related) } } } struct RecipeTile: View { var recipe: Recipe var body: some View { VStack { Rectangle() .fill(Color.secondary.gradient) .frame(width: 240, height: 240) Text(recipe.name) .lineLimit(2, reservesSpace: true) .font(.headline) } .tint(.primary) } } class DataModel: ObservableObject { @Published var recipes: [Recipe] = builtInRecipes func recipes(in category: Category?) -> [Recipe] { recipes .filter { $0.category == category } .sorted { $0.name < $1.name } } subscript(recipeId: Recipe.ID) -> Recipe? { // A real app would want to maintain an index from identifiers to // recipes. recipes.first { recipe in recipe.id == recipeId } } } enum Category: Int, Hashable, CaseIterable, Identifiable, Codable { case dessert case pancake case salad case sandwich var id: Int { rawValue } var localizedName: LocalizedStringKey { switch self { case .dessert: return "Dessert" case .pancake: return "Pancake" case .salad: return "Salad" case .sandwich: return "Sandwich" } } } struct Recipe: Hashable, Identifiable { let id = UUID() var name: String var category: Category var ingredients: [Ingredient] var related: [Recipe.ID] = [] var imageName: String? = nil } struct Ingredient: Hashable, Identifiable { let id = UUID() var description: String static func fromLines(_ lines: String) -> [Ingredient] { lines.split(separator: "\n", omittingEmptySubsequences: true) .map { Ingredient(description: String($0)) } } } let builtInRecipes: [Recipe] = { var recipes = [ "Apple Pie": Recipe( name: "Apple Pie", category: .dessert, ingredients: Ingredient.fromLines(applePie)), "Baklava": Recipe( name: "Baklava", category: .dessert, ingredients: []), "Bolo de Rolo": Recipe( name: "Bolo de rolo", category: .dessert, ingredients: []), "Chocolate Crackles": Recipe( name: "Chocolate crackles", category: .dessert, ingredients: []), "Crème Brûlée": Recipe( name: "Crème brûlée", category: .dessert, ingredients: []), "Fruit Pie Filling": Recipe( name: "Fruit Pie Filling", category: .dessert, ingredients: []), "Kanom Thong Ek": Recipe( name: "Kanom Thong Ek", category: .dessert, ingredients: []), "Mochi": Recipe( name: "Mochi", category: .dessert, ingredients: []), "Marzipan": Recipe( name: "Marzipan", category: .dessert, ingredients: []), "Pie Crust": Recipe( name: "Pie Crust", category: .dessert, ingredients: Ingredient.fromLines(pieCrust)), "Shortbread Biscuits": Recipe( name: "Shortbread Biscuits", category: .dessert, ingredients: []), "Tiramisu": Recipe( name: "Tiramisu", category: .dessert, ingredients: []), "Crêpe": Recipe( name: "Crêpe", category: .pancake, ingredients: []), "Jianbing": Recipe( name: "Jianbing", category: .pancake, ingredients: []), "American": Recipe( name: "American", category: .pancake, ingredients: []), "Dosa": Recipe( name: "Dosa", category: .pancake, ingredients: []), "Injera": Recipe( name: "Injera", category: .pancake, ingredients: []), "Acar": Recipe( name: "Acar", category: .salad, ingredients: []), "Ambrosia": Recipe( name: "Ambrosia", category: .salad, ingredients: []), "Bok l'hong": Recipe( name: "Bok l'hong", category: .salad, ingredients: []), "Caprese": Recipe( name: "Caprese", category: .salad, ingredients: []), "Ceviche": Recipe( name: "Ceviche", category: .salad, ingredients: []), "Çoban salatası": Recipe( name: "Çoban salatası", category: .salad, ingredients: []), "Fiambre": Recipe( name: "Fiambre", category: .salad, ingredients: []), "Kachumbari": Recipe( name: "Kachumbari", category: .salad, ingredients: []), "Niçoise": Recipe( name: "Niçoise", category: .salad, ingredients: []), ] recipes["Apple Pie"]!.related = [ recipes["Pie Crust"]!.id, recipes["Fruit Pie Filling"]!.id, ] recipes["Pie Crust"]!.related = [recipes["Fruit Pie Filling"]!.id] recipes["Fruit Pie Filling"]!.related = [recipes["Pie Crust"]!.id] return Array(recipes.values) }() let applePie = """ ¾ cup white sugar 2 tablespoons all-purpose flour ½ teaspoon ground cinnamon ¼ teaspoon ground nutmeg ½ teaspoon lemon zest 7 cups thinly sliced apples 2 teaspoons lemon juice 1 tablespoon butter 1 recipe pastry for a 9 inch double crust pie 4 tablespoons milk """ let pieCrust = """ 2 ½ cups all purpose flour 1 Tbsp. powdered sugar 1 tsp. sea salt ½ cup shortening ½ cup butter (Cold, Cut Into Small Pieces) ⅓ cup cold water (Plus More As Needed) """ struct MultipleColumnsWithStack_Previews: PreviewProvider { static var previews: some View { MultipleColumnsWithStack() } }
-
18:12 - Use Scene Storage
import SwiftUI import Combine import Foundation // Use SceneStorage to save and restore 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) } } // Make the navigation model Codable 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) } init() {} 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] } } var jsonData: Data? { get { try? JSONEncoder().encode(self) } set { guard let data = newValue, let model = try? JSONDecoder().decode(NavigationModel.self, from: data) else { return } self.selectedCategory = model.selectedCategory self.recipePath = model.recipePath } } var objectWillChangeSequence: AsyncPublisher<Publishers.Buffer<ObservableObjectPublisher>> { objectWillChange .buffer(size: 1, prefetch: .byRequest, whenFull: .dropOldest) .values } } struct RecipeGrid: View { var category: Category? @EnvironmentObject private var dataModel: DataModel 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))] } } struct RecipeDetail: View { @EnvironmentObject private var dataModel: DataModel var recipe: Recipe var body: some View { Text("Recipe details go here") .navigationTitle(recipe.name) ForEach(recipe.related.compactMap { dataModel[$0] }) { related in NavigationLink(related.name, value: related) } } } struct RecipeTile: View { var recipe: Recipe var body: some View { VStack { Rectangle() .fill(Color.secondary.gradient) .frame(width: 240, height: 240) Text(recipe.name) .lineLimit(2, reservesSpace: true) .font(.headline) } .tint(.primary) } } class DataModel: ObservableObject { @Published var recipes: [Recipe] = builtInRecipes static var shared: DataModel { // Just instantiate each time for the example. A real app would need to // persist the data model as well. DataModel() } func recipes(in category: Category?) -> [Recipe] { recipes .filter { $0.category == category } .sorted { $0.name < $1.name } } subscript(recipeId: Recipe.ID) -> Recipe? { // A real app would want to maintain an index from identifiers to // recipes. recipes.first { recipe in recipe.id == recipeId } } } enum Category: Int, Hashable, CaseIterable, Identifiable, Codable { case dessert case pancake case salad case sandwich var id: Int { rawValue } var localizedName: LocalizedStringKey { switch self { case .dessert: return "Dessert" case .pancake: return "Pancake" case .salad: return "Salad" case .sandwich: return "Sandwich" } } } struct Recipe: Hashable, Identifiable { let id: UUID var name: String var category: Category var ingredients: [Ingredient] var related: [Recipe.ID] = [] var imageName: String? = nil } struct Ingredient: Hashable, Identifiable { let id = UUID() var description: String static func fromLines(_ lines: String) -> [Ingredient] { lines.split(separator: "\n", omittingEmptySubsequences: true) .map { Ingredient(description: String($0)) } } } let builtInRecipes: [Recipe] = { var recipes = [ "Apple Pie": Recipe( id: UUID(uuidString: "E35A5C9C-F1EA-4B3D-9980-E2240B363AC8")!, name: "Apple Pie", category: .dessert, ingredients: Ingredient.fromLines(applePie)), "Baklava": Recipe( id: UUID(uuidString: "B95B2D99-F45D-4B74-9EC4-526914FFC414")!, name: "Baklava", category: .dessert, ingredients: []), "Bolo de Rolo": Recipe( id: UUID(uuidString: "E17C729D-1E09-48F6-99E2-5BB959F5AE70")!, name: "Bolo de Rolo", category: .dessert, ingredients: []), "Chocolate Crackles": Recipe( id: UUID(uuidString: "89202A12-2B04-4EFE-ADC5-D1ECE7A25389")!, name: "Chocolate Crackles", category: .dessert, ingredients: []), "Crème Brûlée": Recipe( id: UUID(uuidString: "412EA92A-40B5-4CFE-9379-627A1C80FFE1")!, name: "Crème Brûlée", category: .dessert, ingredients: []), "Fruit Pie Filling": Recipe( id: UUID(uuidString: "4792C8AE-9596-4502-A9CB-806E2DFEA408")!, name: "Fruit Pie Filling", category: .dessert, ingredients: []), "Kanom Thong Ek": Recipe( id: UUID(uuidString: "331C25F6-4FED-4DA5-980E-7E619855DE92")!, name: "Kanom Thong Ek", category: .dessert, ingredients: []), "Mochi": Recipe( id: UUID(uuidString: "1EAA5288-8D2B-4969-AF97-ED591796B456")!, name: "Mochi", category: .dessert, ingredients: []), "Marzipan": Recipe( id: UUID(uuidString: "416F4F5A-A81C-40FD-87F1-060B0F57DE6D")!, name: "Marzipan", category: .dessert, ingredients: []), "Pie Crust": Recipe( id: UUID(uuidString: "D0820C1A-1AFB-4472-97DA-39A475304048")!, name: "Pie Crust", category: .dessert, ingredients: Ingredient.fromLines(pieCrust)), "Shortbread Biscuits": Recipe( id: UUID(uuidString: "3D9FEA8C-B38E-4739-8B4B-424885D76926")!, name: "Shortbread Biscuits", category: .dessert, ingredients: []), "Tiramisu": Recipe( id: UUID(uuidString: "586B9A4C-410A-40D2-AE40-BC32351A5C08")!, name: "Tiramisu", category: .dessert, ingredients: []), "Crêpe": Recipe( id: UUID(uuidString: "9BD6C3B2-30CB-425E-8D60-7F07D0BA720C")!, name: "Crêpe", category: .pancake, ingredients: []), "Jianbing": Recipe( id: UUID(uuidString: "117E5CD4-8FF9-43FB-ACAE-53C35A648F6F")!, name: "Jianbing", category: .pancake, ingredients: []), "American": Recipe( id: UUID(uuidString: "4584B877-E482-4FF2-824E-FC667BFAD271")!, name: "American", category: .pancake, ingredients: []), "Dosa": Recipe( id: UUID(uuidString: "5666FEB6-90DB-4CD2-91FA-D6F00986E90E")!, name: "Dosa", category: .pancake, ingredients: []), "Injera": Recipe( id: UUID(uuidString: "752DAEB8-123E-4C48-A190-79742AA56869")!, name: "Injera", category: .pancake, ingredients: []), "Acar": Recipe( id: UUID(uuidString: "F0D54AF2-04AD-4F08-ACE4-7886FCAE1F7B")!, name: "Acar", category: .salad, ingredients: []), "Ambrosia": Recipe( id: UUID(uuidString: "F7FD59E8-F1AE-4331-8667-D5534817F7E7")!, name: "Ambrosia", category: .salad, ingredients: []), "Bok L'hong": Recipe( id: UUID(uuidString: "3DE38C07-F985-4E05-810C-1108A777766B")!, name: "Bok L'hong", category: .salad, ingredients: []), "Caprese": Recipe( id: UUID(uuidString: "055D963C-0546-4578-AF18-6FBEE249EF35")!, name: "Caprese", category: .salad, ingredients: []), "Ceviche": Recipe( id: UUID(uuidString: "50B62AF4-89AF-4D00-9832-E200FEC01279")!, name: "Ceviche", category: .salad, ingredients: []), "Çoban Salatası": Recipe( id: UUID(uuidString: "87AD6B33-FFD2-4E5C-BC4B-59769F7AC7E3")!, name: "Çoban Salatası", category: .salad, ingredients: []), "Fiambre": Recipe( id: UUID(uuidString: "8A9BC0D5-A931-4381-BDA8-713DF6389FE7")!, name: "Fiambre", category: .salad, ingredients: []), "Kachumbari": Recipe( id: UUID(uuidString: "E9497D38-49E0-4A18-939B-63A3F2C7C0B4")!, name: "Kachumbari", category: .salad, ingredients: []), "Niçoise": Recipe( id: UUID(uuidString: "DE9F7106-4D0C-4EAC-B44C-A8D8ECD81087")!, name: "Niçoise", category: .salad, ingredients: []) ] recipes["Apple Pie"]!.related = [ recipes["Pie Crust"]!.id, recipes["Fruit Pie Filling"]!.id ] recipes["Pie Crust"]!.related = [recipes["Fruit Pie Filling"]!.id] recipes["Fruit Pie Filling"]!.related = [recipes["Pie Crust"]!.id] return Array(recipes.values) }() let applePie = """ ¾ cup white sugar 2 tablespoons all-purpose flour ½ teaspoon ground cinnamon ¼ teaspoon ground nutmeg ½ teaspoon lemon zest 7 cups thinly sliced apples 2 teaspoons lemon juice 1 tablespoon butter 1 recipe pastry for a 9 inch double crust pie 4 tablespoons milk """ let pieCrust = """ 2 ½ cups all purpose flour 1 Tbsp. powdered sugar 1 tsp. sea salt ½ cup shortening ½ cup butter (Cold, Cut Into Small Pieces) ⅓ cup cold water (Plus More As Needed) """ struct UseSceneStorage_Previews: PreviewProvider { static var previews: some View { UseSceneStorage() } }
-
25:33 - Biscuits
import SwiftUI struct Biscuits: View { @State private var step = 0 @ScaledMetric private var fontSize = 18 var body: some View { VStack(alignment: .leading) { HStack { Spacer() VStack { Text("Biscuits") .font(.headline) Text(subtitle) .font(.subheadline) } .padding(16) Spacer() } Spacer() Text(LocalizedStringKey(steps[step])) .font(.system( size: fontSize, weight: .semibold, design: .serif)) .padding(16) .lineLimit(1...) Spacer() HStack { Button { withAnimation { step -= 1 } } label: { Label("Previous", systemImage: "chevron.backward") } .disabled(step - 1 < 0) Spacer() Button { withAnimation { step += 1 } } label: { Label("Next", systemImage: "chevron.forward") } .disabled(step + 1 >= steps.count) } .buttonStyle(CarouselButtonStyle()) .padding(16) } .foregroundStyle(Color.white) .background(gradient) .ignoresSafeArea(edges: .bottom) } var subtitle: LocalizedStringKey { if step == 0 { return "Ingredients" } return "Step \(step)" } var gradient: AngularGradient { AngularGradient( colors: colors, center: UnitPoint(x: 0.5, y: 1.0), angle: .degrees(180 * Double(step) / Double(steps.count - 1))) } } struct CarouselButtonStyle: ButtonStyle { @Environment(\.isEnabled) private var isEnabled func makeBody(configuration: Configuration) -> some View { ZStack { Circle() .fill(.ultraThinMaterial.shadow(.inner( radius: configuration.isPressed ? 3 : 0))) .frame(width: 44, height: 44) configuration.label .labelStyle(.iconOnly) .foregroundStyle(isEnabled ? .black : .secondary) .opacity(configuration.isPressed ? 0.3 : 0.8) } } } let steps = [ """ 2 cups all-purpose flour ¼ teaspoons coarse salt 1 cup (2 sticks) unsalted butter, room temperature ¾ cup confectioners' sugar """, "Sift flour and salt, mix into bowl and set aside.", "Mix butter on high speed until fluffy (3 to 5 minutes).", "Gradually add sugar slowly, continuing to mix until pale and fluffy.", "Add flour all at once and mix until combined.", "Butter a square pan.", "Pat and roll shortbread into pan no more than 1/2-inch thick.", "Refrigerate for at least 30 minutes.", "Preheat oven to 300 F.", "Cut chilled shortbread into squares.", """ Bake until golden and make sure the middle is firm. \ Approximately 45 to 60 minutes. """, "Cool completely. Re-slice them, if necessary, and serve.", ] let colors = [Color.yellow, .red, .purple] struct Biscuits_Previews: PreviewProvider { static var previews: some View { Biscuits() } }
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。