Highlight
SwiftUI 在 iOS 17、macOS Sonoma、tvOS 17 和 watchOS 10 中扩展了 Focus 系统,新增
.focusable(interactions:)精确控制焦点交互类型、@FocusState支持自定义数据类型、focusedSceneValue实现跨视图数据流、focusSection()引导方向键导航,以及onKeyPress和defaultFocus让键盘驱动的交互体验可以精确编程控制。
核心内容
Focus 是注意力的光标
键盘、Apple TV 遥控器和 Apple Watch 数码表冠有一个共同点:它们不提供屏幕坐标。鼠标点击时,系统知道光标在哪里;但按下键盘按键时,系统需要知道输入该发给哪个视图。
(01:05)
Focus 就是解决这个问题的一套机制。它像一种特殊的”注意力光标”,标记当前应该接收输入的视图。macOS 会给焦点视图加边框,watchOS 会画绿色边框,tvOS 会给焦点视图添加悬浮效果。
(02:17)
两种焦点交互类型
iOS 17 之前,.focusable 只有一种行为。现在你可以精确指定交互类型:
.focusable(interactions: .edit):用于需要持续焦点输入的控件,如文本框。点击就能获得焦点。.focusable(interactions: .activate):用于将焦点作为点击替代的控件,如按钮。只有在系统开启”键盘导航”时才能通过 Tab 键获得焦点。
(05:04)
如果你之前在 macOS 上用了 .focusable,升级到 Sonoma 后需要检查行为是否符合预期,因为默认行为可能已经改变。
FocusState 管理焦点位置
@FocusState 让你可以观察和编程式控制焦点位置。Bool 类型用于单个视图,自定义 Hashable 类型可以管理多个视图中的焦点。
(06:12)
Focused Values 连接远程 UI
菜单命令、工具栏按钮等 UI 元素远离主内容区域,但它们需要知道当前焦点上下文。Focused Values 通过定义 FocusedValueKey,让数据随焦点移动自动更新到远程 UI。
(07:32)
Focus Sections 引导导航路径
在复杂布局中,方向键或 Tab 键的焦点移动可能不符合直觉。focusSection() 可以把一组视图标记为一个导航区域,引导焦点朝正确的方向移动。
(10:03)
详细内容
自定义控件的焦点交互
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)
}
}
(05:05)
关键点:
.focusable(interactions: .edit)让控件在点击时获得焦点,适合文本输入类控件.focusable(interactions: .activate)让控件只在键盘导航开启时通过 Tab 获得焦点,适合按钮类控件- 不指定参数时,控件对所有交互类型都支持焦点
- macOS 上已有的
.focusable代码升级到 Sonoma 后行为可能变化,需要验证
用 FocusState 观察和控制焦点
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)
}
}
(06:12)
关键点:
@FocusState声明一个与焦点位置绑定的属性.focused($isItemFocused)把 TextField 与 FocusState 绑定- 点击”Done”按钮时设置
isItemFocused = false,焦点会从文本框移开 - 按钮的
disabled状态根据焦点状态动态变化
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) { /* ... */ }
}
(07:32)
关键点:
- 定义
FocusedValueKey协议,指定值的类型 - 扩展
FocusedValues提供类型安全的访问属性 .focusedSceneValue(\.selectedRecipe, $recipe)在当前视图有焦点时提供数据@FocusedBinding在菜单命令中读取焦点上下文的数据- 焦点移动时,菜单命令会自动更新
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) { /* ... */ }
}
(10:03)
关键点:
.focusSection()标记一个区域为焦点导航的目标- Focus section 本身不会获得焦点,但会引导焦点向最近的焦点内容移动
- 要让 focus section 有效,它的尺寸必须大于内部内容(用
Spacer扩展) - 在 tvOS 上,这解决了”从网格某行向左滑动无法到达侧边栏”的问题
编程式控制焦点移动
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
}
}
(11:29)
关键点:
@FocusState使用自定义Hashable类型(这里是Item.ID)管理多个视图中的焦点.focused($focusedItem, equals: item.id)把每个 TextField 与对应的 ID 绑定.defaultFocus($focusedItem, list.items.last?.id)在屏幕首次出现时自动把焦点设到最后一个项目addEmptyItem()创建新项目后立即更新focusedItem,焦点自动跳转到新文本框
自定义焦点控件支持键盘和数码表冠
struct RatingPicker: View {
@Environment(\.layoutDirection) private var layoutDirection
@Binding var rating: Rating?
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
}
}
}
}
(15:25)
关键点:
.contentShape(.capsule)让焦点环跟随胶囊形状,而不是默认的矩形.focusable(interactions: .activate)指定为激活类型,点击不会获得焦点.onMoveCommand处理方向键输入,在 macOS 上用左右箭头切换选项.digitalCrownRotation在 watchOS 上绑定数码表冠旋转- 用
layoutDirection环境值处理从右到左语言的布局翻转
可聚焦网格支持键盘导航
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)
}
}
}
(18:50)
关键点:
.focusable()让整个网格获得焦点,不指定交互类型时支持所有交互.focusEffectDisabled()关闭自动焦点环,因为选中状态已经传达了焦点信息.focusedValue(\.selectedRecipe, $selection)把当前选择暴露给菜单命令.onMoveCommand处理方向键,更新选中项.onKeyPress(.return)处理回车键,进入详情.onKeyPress(characters: .alphanumerics)实现首字母快速定位
核心启发
1. 给 macOS 应用加上完整的键盘导航
如果你的应用在 macOS 上运行,打开系统设置中的”键盘导航”开关,测试所有交互是否可以通过 Tab 和方向键完成。给自定义控件加上 .focusable(interactions: .activate),给容器加上 .focusSection() 引导导航路径。入口是系统设置 > 键盘 > 键盘导航。
2. 用 Focused Values 简化菜单命令
你的菜单命令(如”添加到收藏”、“删除”)需要根据当前焦点上下文决定可用状态。用 FocusedValueKey + @FocusedBinding 替代手动传递选中状态。入口是定义一个 FocusedValueKey,在主视图上用 .focusedSceneValue 提供数据,在 Commands 中用 @FocusedBinding 读取。
3. 实现 grocery list 式的自动焦点跳转
在表单或列表中,用户添加新项目后自动把焦点跳转到新输入框。用 @FocusState 绑定项目 ID,创建新项目后更新绑定值。入口是给每个输入框加上 .focused($focusedItem, equals: item.id),在添加方法中设置 focusedItem = newItem.id。
4. 给 tvOS 应用添加焦点区域
tvOS 上焦点通过遥控器方向键移动。如果侧边栏和主内容之间无法直接导航,给每个区域加上 .focusSection()。入口是找到需要跨区导航的布局,在外层容器上添加 .focusSection(),确保容器尺寸大于内部内容。
5. 用 onKeyPress 实现键盘快捷键
在 macOS 和 iPadOS 上,给复杂界面添加键盘快捷键。回车键确认、Escape 取消、字母键快速定位。入口是在焦点容器上添加 .onKeyPress(.return) 或 .onKeyPress(characters:) 修饰符。
关联 Session
- What’s new in SwiftUI — SwiftUI 年度新特性汇总
- The SwiftUI cookbook for navigation — SwiftUI 导航模式详解
- Meet watchOS 10 — Apple Watch 新特性与数码表冠交互
- Design for tvOS — tvOS 焦点驱动设计指南
评论
GitHub Issues · utterances