大多数浏览器和
Developer App 均支持流媒体播放。
-
SwiftUI 聚焦的秘诀
SwiftUI 团队带着强大的工具回到了编码“厨房”,以打造 App 的聚焦体验。和我们一同了解 App 中焦点驱动交互的基本构成要素。探索自定义视图的聚焦交互,了解键盘输入的按键处理程序,并学习如何通过焦点区域支持移动和层次结构。我们还将介绍一些适用于你的 App 中常见焦点模式的美味秘诀。
章节
- 1:44 - What is focus
- 3:18 - Ingredients
- 3:35 - Ingredients: Focusable views
- 6:04 - Ingredients: Focus state
- 7:03 - Ingredients: Focused values
- 8:54 - Ingredients: Focused sections
- 10:45 - Recipes
- 11:21 - Recipes: Controlling focus
- 14:36 - Recipes: Custom focusable control
- 18:04 - Recipes: Grid view
资源
相关视频
WWDC23
-
下载
♪ ♪
Cody:大家好 欢迎来到 “SwiftUI 聚焦的烹饪法则” 我是 Cody 今天我将为大家介绍 如何使用 SwiftUI 中的聚焦 API “烹饪”出优秀的用户体验 在本视频中 我将为你奉上固定菜单中的 三道菜肴 包含美味的 API 详情介绍 佐以精美代码示例 作为开胃菜 我将花些时间 回顾聚焦的基本知识: 什么是聚焦?它的作用是什么? 作为第一道菜 我们将介绍 打造聚焦体验所需要的“食材” 来激发你的味蕾 有了这些“食材” 我就可以开始烹饪了 对于主菜 我将深入探讨一些 “食谱” 介绍如何控制焦点外观、 观察焦点移动 以及使用自定义控件响应键盘输入 那么 什么是聚焦? 聚焦是一种工具 用于决定 当有人在键盘上按下一个键、 在 Apple TV 遥控器上滑动 或是旋转手表上的数码表冠时 该如何做出响应 这些输入方式有一个重要的共同点 那就是它们本身 并不能提供足够的信息 来确定它们的输入 是用于操作屏幕上的哪个控件 以鼠标、触控板 和触摸屏等设备类比 当你使用鼠标或触控板时 屏幕上的光标会将 你的点击与屏幕坐标关联起来 系统则利用该坐标 来确定交互的目标 焦点则提供了系统需要的额外信息 以便在没有指针光标的情况下 也能进行输入的定向 当一个视图有焦点时 系统会将其作为起始点来响应 来自键盘、Apple TV 遥控器 和 Apple Watch 数码表冠的输入
聚焦不仅仅是一个执行细节 它对于使用你的 App 的人来说 同样重要 这也是为什么开发者 应着重强调带有焦点的视图 macOS 会自动 在焦点视图周围添加一个边框 以说明该视图将接收键盘输入 watchOS 会在控件周围 绘制一个绿色边框 以表示该控件的值 可以通过旋转数码表冠来更改 而在 Apple tvOS 上 焦点视图会产生一个悬停效果 使其从其他控件所在平面抬升起来 强调焦点视图可以 从多个方面帮助用户 当用户在键盘上输入 或在遥控器上滑动时 他们可以预先知道输入的位置在哪 在复杂或详细的布局中 焦点视图可以一目了然地提醒用户 他们正在与 App 的 哪个部分进行交互 焦点的行为很像 一种特殊类型的光标 它不像鼠标光标那样 跟踪屏幕上的一个点 而是追踪你的 UI 中哪一部分 才是焦点输入目标 因此 我喜欢将焦点看作是 代表用户注意力的光标 现在你已经了解聚焦是什么 以及它在你的 App 中的显示方式 我可以呈上第一道菜了 即构成每个 App 的 聚焦体验的基本要素: 可聚焦的视图、聚焦状态、 聚焦的值和聚焦区域 在使用聚焦进行烹饪时 我们需要考虑的主要“食材” 就是可聚焦的视图本身 可聚焦视图是系统在响应焦点输入时 使用的起始点 不同的控件 可以在不同的情况下成为焦点 当然其原因也不同 我们可以比较一下 macOS 和 iPadOS 上的文本字段和按钮 文本字段始终可以成为焦点对象 无论用户是点击它们 还是按下 Tab 键 将焦点从之前的控件上移开 这类控件支持在编辑中聚焦 因为其功能 就是捕捉连续的焦点输入
而按钮不同 它们的功能是处理点击和触摸 当你点击按钮时 macOS 和 iPadOS 不会将焦点放在按钮上 而使用 Tab 键 访问按钮的唯一方法 就是打开全系统的键盘导航 如果你对此设置不熟悉 你可以在 macOS“系统设置”的 “键盘”面板中找到它 它是标示为“键盘导航”的开关 在打开这个开关后 我就可以 按 Tab 键将焦点放在按钮上 并按空格键来激活它们
按钮支持焦点激活 这种控件不需要焦点 也能完成它们的工作 但如果系统允许 它们也可以获得焦点 用焦点驱动的方式 来代替点击和触摸输入 在 iOS 17 和 macOS Sonoma 中 我们推出了新的 API 可以让自定义控件参与焦点系统 当开发者使用 “focusable”视图修饰符时 你现在可以通过指定控件 支持的焦点交互类型来微调其行为 对于使用焦点来 随时间更新状态的控件 请指定编辑交互 对于使用焦点作为 直接指针激活的替代方式的控件 请指定激活交互
如果开发者不提供任何参数 系统将为控件的所有交互提供焦点 在 macOS Sonoma 之前 focusable 修饰符 仅支持激活语义 如果你之前已经在 macOS 代码中使用了 focusable 修饰符 请检查其新行为是否适合你的用例 你可能需要添加一个 “interactions”参数来更新你的代码 下一种“食材” 与聚焦系统每时每刻的状态有关 这种“食材”被恰当地命名为 “FocusState” 系统会记录哪个视图有焦点 而 App 可以在其逻辑中 利用这些信息 来决定如何处理输入 以及如何设置视图样式 为了观察系统的状态 开发者可以创建绑定 将你提供的值 与特定视图上的焦点关联起来 视图可以读取这些绑定 以便在焦点发生变化时得到通知 例如一个视图被聚焦 或者焦点被取消 具有 Boolean 值的聚焦状态属性 将告诉你某个视图是否处于焦点状态 如此处所示 对于更复杂的情况 你也可以使用自定义数据类型 稍后 我将介绍一个这样的例子 并展示如何通过编程方式 更改聚焦状态
下一种“食材”是 FocusedValue API FocusedValue API 解决了 如何建立数据依赖关系的问题 这些依赖关系 连接了你的 UI 的远程部分 你可以使用此 API 根据 当前活动场景的 情况更新 App 的命令 焦点值可以让数据流 在这些不同元素中移动 我将定义一个自定义值 并使用它来构建我的主菜单内容 创建和使用焦点值类似于创建和使用 自定义环境键值和对象 你可以使用“FocusedValueKey” 协议来定义一个新的键值 然后使用一个计算属性 扩展“FocusedValues” 该计算属性使用新键值 来获取和设置值 你使用的数据来自你场景的视图 它可以是一个值、 绑定或可观察对象 无论如何 你要使用一组视图修饰符将数据 与视图层次结构 该部分中的焦点关联起来 与环境值一样 开发者可以通过 声明一个动态属性来访问焦点值 在这个例子中 我的焦点值是一个绑定 所以我会使用 “@FocusedBinding”属性包装器 并为其提供我自定义的键值路径 “@FocusedBinding”会查看 聚焦的视图及其父视图 以确认当前是否有 与该键值相关联的绑定 该属性包装器会自动解包绑定 以便我可以 直接使用绑定值进行操作 我接下来唯一需要做的事 就是在视图主体中使用我的新属性 随着时间的推移 当焦点在不同控件之间移动 并且不同窗口变为活动状态时 系统将更新视图 以反映其在新环境中找到的值 最后一种“食材”是 focusSection API 你可以使用焦点区域控制 在用户滑动 Apple TV 遥控器 或在键盘上按 Tab 键时 焦点如何移动 默认情况下 焦点从会从最靠近屏幕 前缘的顶端控件开始 从这开始 按下 Tab 键会将焦点 按照当前区域设置的布局顺序 从一个控件移动到下一个控件 当到达屏幕上的最后一个控件时 再次按下 Tab 键 将重新开始这个序列 用 Apple TV 遥控器进行的 焦点移动是有方向性的 用户可以通过上下左右滑动 来在控件之间移动焦点 但定向移动仅适用于相邻的目标 在本例中 我可以从 Creme Brûlée 按钮向右 滑动到其他的甜点 但如果我想把烤布蕾的配料 加入购物清单 我不能直接向下滑 因为这个按钮 不在烤布蕾按钮的正下方 所以我的操作失败了 为了让这些焦点目标对齐 我要将底部按钮的包装器 标记为焦点区域 焦点区域可以成为移动手势的目标 但它们本身不可聚焦 相反 它们会将焦点引导到 最近的可聚焦内容 为了达到效果 焦点区域 必须占据比其内容更多的空间 在本例中 我将在按钮前后 都添加一个 spacer 使堆栈变宽以适应屏幕的宽度 现在有了更大的焦点目标 我可以从任何位置向下滑 以达到底部按钮 我可以品尝这个烤布蕾了!
接下来我将带你了解一些“食谱” 它们结合了 我刚刚介绍的基本“食材” 你可以用它们 来完善自定义控件的外观和风格 并消除常见任务中的障碍 最近 我一直在使用我的厨师同事 Curt 制作的食谱 App 你可能在他的 WWDC22 讲座中 见过这个 App 本节中的食谱 以我正在开发的一些新功能为基础 而我们可以通过关注其聚焦行为 来改善它的表现 例如 我添加了一个 App 内购物清单 可以帮我记住下次 去购物时要买什么 第一份“食谱”展示了 如何通过一点编程上的焦点移动 让编辑购物清单变成 一种愉快的体验 当购物清单弹窗出现时 它在最后总是有一个空白项 当我们点击空白项 键盘就会弹出 这样我可以编辑我要购买的物品 添加要购买的东西是一个频繁的 操作 所以我希望每次清单出现时 焦点能自动出现在空白项上 这样我就不用每次都点击它 之前 我展示了如何使用 focusState API 来观察和更新具有焦点的视图 我将在这里使用相同的 API 之前的例子使用了一个标志 来表示单个视图是否具有焦点 对于我的购物清单来说 可能会有很多文本字段需要观察 在这种情况下 focusState 的值 可以是任何 Hashable 类型 我在这个屏幕上添加的 每个食材都有一个唯一的 ID 我可以通过存储与焦点文本字段 相关的 ID 来跟踪焦点 我将使用 “focused(_:equals:)”修饰符 来建立每个文本字段 与其食材之间的关联 我需要向这个修饰符提供两个参数: 一个是对“focusedItem” 属性的绑定 另一个是当焦点在该文本字段时 用于绑定更新的食材 ID 现在我可以运行 App 并验证 在我点击购物清单时 “focusedItem”属性 是否会被更新为不同的 ID 值
有了焦点状态绑定后 当购物清单首次出现在屏幕上时 我就可以通过编程的方式 将焦点移动到文本字段上 我通过向列表中添加 “defaultFocus(_:_:)”视图修饰符 来实现这一点 这在 iOS 17 中也可用 当系统在此屏幕上首次评估焦点时 它将尝试使用购物清单 最后一项的 ID 来更新我的绑定
通过这些改变 现在我添加 购物清单只需要两个步骤 点击工具栏中的按钮打开弹窗 然后直接开始输入 没有其他步骤了 随着我的购物清单越来越长 当我在工具栏中点击 Add 按钮 会创建一个新的空白列表项 但焦点还停留在原来的位置 我必须点击新的空白项 才能将焦点移到它上面 这又是一个我希望 App 能以编程方式移动焦点的场景 以便我能在新空白项出现后 直接开始输入 与之前不同的是 现在我想控制焦点变化的时机 好消息是 我可以使用我之前 为设置默认焦点创建的 聚焦状态绑定 在我的 GroceryListView 中 我有一个“addEmptyItem”方法 用于在我的模型中添加一个新项目 而且 由于我已经 将新项目的文本字段 与“currentItemID” 属性关联起来 我只需要 使用新 ID 更新该属性 让其作为工具栏按钮操作的一部分
请看! 现在当我想要编辑 或更新我的购物清单时 我不需要点击任何地方 即可将焦点放在它应该在的位置 我只需要开始输入即可
接下来 让我们使用更多的“食材” 来改善我创建的 自定义控件的聚焦交互 到目前为止 我已经整理了很多食谱 当我尝试每一个食谱时 我想要记住哪些效果很好 哪些需要重新调整 或者至少需要多加一点盐 为了达到这个目的 我构建了一个 带有表情符号的自定义选择控件 以记录我的烹饪之旅的高潮和低谷 我可以通过点击表情符号 来评价每个食谱 但作为一个习惯于键盘导航的人 我真的很想用 Tab 键聚焦该控件 并使用方向键来更改选择 让我们来实现这个功能 这是我的表情符号 选择器的基本结构: 首先 我需要让控件变为可聚焦 我首先添加不带参数的 “focusable”修饰符 这样 在按下 Tab 键时 我的控件就可以聚焦 但我注意到它还有一些 与其他按钮和类似控件不同的行为 例如 我的控件可以 在点击后获得焦点 而按钮和分段控件则不会 这些控件需要 “键盘导航”才能被聚焦 我的控件也应该如此 为了实现这一点 我将指定我的控件 在激活时才可聚焦 针对激活而可聚焦的控件 在单击时不会获得焦点 并且它们需要在“键盘导航”打开时 才能通过键盘接收焦点 我接下来注意到的是 macOS 在我的控件周围 绘制的焦点环是矩形的 为了完善这个外观 我希望焦点环 能变得和胶囊形状的背景一样 焦点环总是遵循视图的内容形状 我的视图形状默认是矩形 我将使用 “contentShape”修饰符 并传入与我用于视觉裁剪视图的 相同的胶囊形状 现在我的控件已经可聚焦了 下一步就是让它处理按键事件 我希望能够使用左右方向键 来改变选中的评价 使用“onMoveCommand”修饰符 我可以提供一个要执行的操作 以响应平台适用的移动命令 例如在 Mac 键盘上按下方向键 或在 Apple TV 遥控器上 点击方向边 系统会根据移动方向调用操作 所以我可以根据它来左右移动评级 对于使用从右向左 书写的语言的用户 如阿拉伯语和希伯来语 控件内容应水平翻转 请确保你的移动命令动作 使用环境的“layoutDirection” 来应对这个问题 实现聚焦行为的一个好处在于 我可以让同一个控件在 Apple Watch App 中也有出色的表现 为了处理 watchOS 上的焦点输入 我使用“digitalCrownRotation” 而不是“onMoveCommand”修饰符 当控件获得焦点时 我将使用 isFocused 环境值 在控件周围 绘制我们熟悉的绿色边框
只需几个修饰符 我就获得简单的控件 添加对键盘和数码表冠的支持 最后一个“食谱” 是一个可聚焦的网格视图 我一直会构建它来展示我拍摄的 成品照片 我将其构建成了一个惰性网格 并已经实现了一些选择行为 所以当我点击图像会选择它 双击则会进入食谱的详细视图 接下来我要考虑 它应该如何处理聚焦交互 具体来说 我希望在按下 Tab 键时 网格能够获得焦点 当获得焦点时 我希望用方向键来更新选择 按下 Return 键能够跳转到 所选食谱的详细视图 我将使用我之前介绍 过的几种“食材” 再加上一些额外的内容 来帮助我处理按键操作 并自定义网格在获得焦点时的外观 与之前的例子一样 第一步是使网格变得可聚焦 在这里 我不需要指定任何交互 默认情况下 不管我是用鼠标点击网格 还是按键盘的 Tab 键 网格都会获得焦点 无论“键盘导航”是否启用 这就是我想要的效果 现在我已经让网格可聚焦了 系统会自动 在其周围绘制一个焦点环 对于可选择内容的包装器 这个效果有些多余 我在选中的食谱周围 添加的彩色边框已经可以表明 网格拥有焦点 我可以使用 “focusEffectDisabled”修饰符 来关闭自动添加的焦点环 使用“SelectionShapeStyle”来设置边框 和其他指示符 以表示视图被选中 它会自动适应我选择的重点色 并在其父视图都没有 焦点时变为灰色 例如焦点从网格移动到边栏时 我接下来要做的是 设置一个主菜单命令 用于将选中的食谱标记为收藏 我将使用 FocusedValues API 并传入一个绑定到我的选择 以便根据需要更新菜单命令 为了支持方向键选择 我将使用 onMoveCommand 修饰符 当系统调用时 我将使用移动方向 来更新网格中选定的食谱 最后 我希望有一种方法 可以在按下 Return 键时 对所选内容进行操作并导航到它 我可以使用“onKeyPress” 修饰符来实现这一点 该修饰符是在 macOS Sonoma 和 iOS 17 中新增的 该修饰符接受一组 键值或字符 以及在 连接的硬件键盘上 按下其中任何键时执行的操作 如果操作未处理按键 则返回“ignored” 并且调度应继续 沿视图层次结构向上 作为额外功能 我还将使用“onKeyPress” 来实现 TypeSelection 这样我就可以通过 输入其名称的第一个字母 快速滚动到一个食谱并选择它
现在我已经为 macOS 上的网格 构建了出色的键盘体验 让我们转向 Apple tvOS 上的网格 在 Apple tvOS 上 网格中的每个单元格都可聚焦 因此当使用遥控器 在不同方向上移动焦点时 在这个方向上的单元格将被聚焦 并从视觉上看高于其他单元格 系统会默认对 Buttons 和 NavigationLinks 使用“抬升”悬浮效果 这种效果适用于带有文本 或文本与图像组合的视图 然而 这些食谱照片 可能用不同的效果会更好 在 Apple tvOS 17 中 我现在可以 将高光悬停效果应用于可聚焦视图 当我滑动遥控器时 这种效果为焦点项 添加了透视变换和镜面光泽 让艺术作品和照片看起来非常棒 就像我的食谱缩略图一样 我将添加焦点区域 来为我的 Apple tvOS App 锦上添花 网格位于按钮列表旁边 我需要经常在这两个分组之间导航 在使用 App 时 我碰到了一个老毛病 当焦点位于网格中的较低的行时 我无法向左滑动以移动分类按钮上 因为这两个焦点目标不相邻 我现在将分类按钮列表放在一个 横跨整个布局高度的焦点区域中 现在当我从烤布蕾再向左滑时 焦点就会像预期的那样 移动到了分类按钮上 网格就完成了
太棒了! 我在这个视频中介绍了很多内容 是时候利用这些聚焦“食材” 做些什么了 启用键盘导航 测试一下 你的 macOS 和 iPadOS App 并将默认焦点放在最有用的地方 最后 你可以将控件 安排在焦点区域中 以更好引导焦点在 不规则布局中移动 谢谢你 祝你用餐愉快!
-
-
5:05 - Focusable views
// Focusable views struct RecipeGrid: View { var body: some View { LazyVGrid(columns: [GridItem(), GridItem()]) { ForEach(0..<4) { _ in Capsule() } } .focusable(interactions: .edit) } } struct RatingPicker: View { var body: some View { HStack { Capsule() ; Capsule() } .focusable(interactions: .activate) } }
-
6:12 - Focus state
// Focus state struct GroceryListView: View { @FocusState private var isItemFocused @State private var itemName = "" var body: some View { TextField("Item Name", text: $itemName) .focused($isItemFocused) Button("Done") { isItemFocused = false } .disabled(!isItemFocused) } }
-
7:32 - Focused values
// Focused values struct SelectedRecipeKey: FocusedValueKey { typealias Value = Binding<Recipe> } extension FocusedValues { var selectedRecipe: Binding<Recipe>? { get { self[SelectedRecipeKey.self] } set { self[SelectedRecipeKey.self] = newValue } } } struct RecipeView: View { @Binding var recipe: Recipe var body: some View { VStack { Text(recipe.title) } .focusedSceneValue(\.selectedRecipe, $recipe) } } struct RecipeCommands: Commands { @FocusedBinding(\.selectedRecipe) private var selectedRecipe: Recipe? var body: some Commands { CommandMenu("Recipe") { Button("Add to Grocery List") { if let selectedRecipe { addRecipe(selectedRecipe) } } .disabled(selectedRecipe == nil) } } private func addRecipe(_ recipe: Recipe) { /* ... */ } } struct Recipe: Hashable, Identifiable { let id = UUID() var title = "" var isFavorite = false }
-
10:03 - Focus sections
// Focus sections struct ContentView: View { @State private var favorites = Recipe.examples @State private var selection = Recipe.examples.first! var body: some View { VStack { HStack { ForEach(favorites) { recipe in Button(recipe.name) { selection = recipe } } } Image(selection.imageName) HStack { Spacer() Button("Add to Grocery List") { addIngredients(selection) } Spacer() } .focusSection() } } private func addIngredients(_ recipe: Recipe) { /* ... */ } } struct Recipe: Hashable, Identifiable { static let examples: [Recipe] = [ Recipe(name: "Apple Pie"), Recipe(name: "Baklava"), Recipe(name: "Crème Brûlée") ] let id = UUID() var name = "" var imageName = "" }
-
11:29 - Controlling focus
struct GroceryListView: View { @State private var list = GroceryList.examples @FocusState private var focusedItem: GroceryList.Item.ID? var body: some View { NavigationStack { List($list.items) { $item in HStack { Toggle("Obtained", isOn: $item.isObtained) TextField("Item Name", text: $item.name) .onSubmit { addEmptyItem() } .focused($focusedItem, equals: item.id) } } .defaultFocus($focusedItem, list.items.last?.id) .toggleStyle(.checklist) } .toolbar { Button(action: addEmptyItem) { Label("New Item", systemImage: "plus") } } } private func addEmptyItem() { let newItem = list.addItem() focusedItem = newItem.id } } struct GroceryList: Codable { static let examples = GroceryList(items: [ GroceryList.Item(name: "Apples"), GroceryList.Item(name: "Lasagna"), GroceryList.Item(name: "") ]) struct Item: Codable, Hashable, Identifiable { var id = UUID() var name: String var isObtained: Bool = false } var items: [Item] = [] mutating func addItem() -> Item { let item = GroceryList.Item(name: "") items.append(item) return item } } struct ChecklistToggleStyle: ToggleStyle { func makeBody(configuration: Configuration) -> some View { Button { configuration.isOn.toggle() } label: { Image(systemName: configuration.isOn ? "checkmark.circle.fill" : "circle.dashed") .foregroundStyle(configuration.isOn ? .green : .gray) .font(.system(size: 20)) .contentTransition(.symbolEffect) .animation(.linear, value: configuration.isOn) } .buttonStyle(.plain) .contentShape(.circle) } } extension ToggleStyle where Self == ChecklistToggleStyle { static var checklist: ChecklistToggleStyle { .init() } }
-
15:25 - Custom focusable control
struct RatingPicker: View { @Environment(\.layoutDirection) private var layoutDirection @Binding var rating: Rating? #if os(watchOS) @State private var digitalCrownRotation = 0.0 #endif var body: some View { EmojiContainer { ratingOptions } .contentShape(.capsule) .focusable(interactions: .activate) #if os(macOS) .onMoveCommand { direction in selectRating(direction, layoutDirection: layoutDirection) } #endif #if os(watchOS) .digitalCrownRotation($digitalCrownRotation, from: 0, through: Double(Rating.allCases.count - 1), by: 1, sensitivity: .low) .onChange(of: digitalCrownRotation) { oldValue, newValue in if let rating = Rating(rawValue: Int(round(digitalCrownRotation))) { self.rating = rating } } #endif } private var ratingOptions: some View { ForEach(Rating.allCases) { rating in EmojiView(rating: rating, isSelected: self.rating == rating) { self.rating = rating } } } #if os(macOS) private func selectRating( _ direction: MoveCommandDirection, layoutDirection: LayoutDirection ) { var direction = direction if layoutDirection == .rightToLeft { switch direction { case .left: direction = .right case .right: direction = .left default: break } } if let rating { switch direction { case .left: guard let previousRating = rating.previous else { return } self.rating = previousRating case .right: guard let nextRating = rating.next else { return } self.rating = nextRating default: break } } } #endif } private struct EmojiContainer<Content: View>: View { @Environment(\.isFocused) private var isFocused private var content: Content #if os(watchOS) private var strokeColor: Color { isFocused ? .green : .clear } #endif init(@ViewBuilder content: @escaping () -> Content) { self.content = content() } var body: some View { HStack(spacing: 2) { content } .frame(height: 32) .font(.system(size: 24)) .padding(.horizontal, 8) .padding(.vertical, 6) .background(.quaternary) .clipShape(.capsule) #if os(watchOS) .overlay( Capsule() .strokeBorder(strokeColor, lineWidth: 1.5) ) #endif } } private struct EmojiView: View { var rating: Rating var isSelected: Bool var action: () -> Void var body: some View { ZStack { Circle() .fill(isSelected ? Color.accentColor : Color.clear) Text(verbatim: rating.emoji) .onTapGesture { action() } .accessibilityLabel(rating.localizedName) } } } enum Rating: Int, CaseIterable, Identifiable { case meh case yummy case delicious var id: RawValue { rawValue } var emoji: String { switch self { case .meh: return "😕" case .yummy: return "🙂" case .delicious: return "🥰" } } var localizedName: LocalizedStringKey { switch self { case .meh: return "Meh" case .yummy: return "Yummy" case .delicious: return "Delicious" } } var previous: Rating? { let ratings = Rating.allCases let index = ratings.firstIndex(of: self)! guard index != ratings.startIndex else { return nil } let previousIndex = ratings.index(before: index) return ratings[previousIndex] } var next: Rating? { let ratings = Rating.allCases let index = ratings.firstIndex(of: self)! let nextIndex = ratings.index(after: index) guard nextIndex != ratings.endIndex else { return nil } return ratings[nextIndex] } }
-
18:50 - Grid view
struct ContentView: View { @State private var recipes = Recipe.examples @State private var selection: Recipe.ID = Recipe.examples.first!.id @Environment(\.layoutDirection) private var layoutDirection var body: some View { LazyVGrid(columns: columns) { ForEach(recipes) { recipe in RecipeTile(recipe: recipe, isSelected: recipe.id == selection) .id(recipe.id) #if os(macOS) .onTapGesture { selection = recipe.id } .simultaneousGesture(TapGesture(count: 2).onEnded { navigateToRecipe(id: recipe.id) }) #else .onTapGesture { navigateToRecipe(id: recipe.id) } #endif } } .focusable() .focusEffectDisabled() .focusedValue(\.selectedRecipe, $selection) .onMoveCommand { direction in selectRecipe(direction, layoutDirection: layoutDirection) } .onKeyPress(.return) { navigateToRecipe(id: selection) return .handled } .onKeyPress(characters: .alphanumerics, phases: .down) { keyPress in selectRecipe(matching: keyPress.characters) } } private var columns: [GridItem] { [ GridItem(.adaptive(minimum: RecipeTile.size), spacing: 0) ] } private func navigateToRecipe(id: Recipe.ID) { // ... } private func selectRecipe( _ direction: MoveCommandDirection, layoutDirection: LayoutDirection ) { // ... } private func selectRecipe(matching characters: String) -> KeyPress.Result { // ... return .handled } } struct RecipeTile: View { static let size = 240.0 static let selectionStrokeWidth = 4.0 var recipe: Recipe var isSelected: Bool private var strokeStyle: AnyShapeStyle { isSelected ? AnyShapeStyle(.selection) : AnyShapeStyle(.clear) } var body: some View { VStack { RoundedRectangle(cornerRadius: 20) .fill(.background) .strokeBorder( strokeStyle, lineWidth: Self.selectionStrokeWidth) .frame(width: Self.size, height: Self.size) Text(recipe.name) } } } struct SelectedRecipeKey: FocusedValueKey { typealias Value = Binding<Recipe.ID> } extension FocusedValues { var selectedRecipe: Binding<Recipe.ID>? { get { self[SelectedRecipeKey.self] } set { self[SelectedRecipeKey.self] = newValue } } } struct RecipeCommands: Commands { @FocusedBinding(\.selectedRecipe) private var selectedRecipe: Recipe.ID? var body: some Commands { CommandMenu("Recipe") { Button("Add to Grocery List") { if let selectedRecipe { addRecipe(selectedRecipe) } } .disabled(selectedRecipe == nil) } } private func addRecipe(_ recipe: Recipe.ID) { /* ... */ } } struct Recipe: Hashable, Identifiable { static let examples: [Recipe] = [ Recipe(name: "Apple Pie"), Recipe(name: "Baklava"), Recipe(name: "Crème Brûlée") ] let id = UUID() var name = "" var imageName = "" }
-
21:28 - Focusable grid on tvOS
struct ContentView: View { var body: some View { HStack { VStack { List(["Dessert", "Pancake", "Salad", "Sandwich"], id: \.self) { NavigationLink($0, destination: Color.gray) } Spacer() } .focusSection() ScrollView { LazyVGrid(columns: [GridItem(), GridItem()]) { RoundedRectangle(cornerRadius: 5.0) .focusable() } } .focusSection() } } }
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。