Highlight
iPadOS 15 和 Mac Catalyst 为 UIKit 引入键盘焦点导航,开发者可以让列表、集合视图和自定义视图响应 Tab、方向键与 Return,并用焦点组控制 Tab 循环顺序。
核心内容
很多 iPad 用户会接上硬件键盘。以前,App 想让用户用键盘在界面里移动,常见做法是自己监听 Tab、方向键、Return,再手写一套导航逻辑。
这套方案很快会变复杂。照片网格、侧边栏、搜索框、列表列之间的关系都要自己维护。Mac Catalyst 还要考虑空格键选择、菜单快捷键和系统行为冲突。
iPadOS 15 把这件事交给 UIKit 的焦点系统。Tab 在 App 的重要区域之间移动,方向键在当前区域内移动,Return 在 iPadOS 上选择项目,空格键在 Mac Catalyst 上选择项目。
编译到 iOS 15 SDK 后,文本框、文本视图和侧边栏会自动启用这套行为。集合视图、表格视图和自定义视图需要开发者显式接入。
这场 session 的重点很明确:不要让每个控件都能被键盘焦点选中。键盘导航服务于关键内容区域,例如文本输入、列表和集合视图。按钮、分段控件、开关这类控件已经由 Full Keyboard Access(全键盘访问)覆盖。
从“能不能聚焦”开始
焦点系统先回答一个问题:这个元素能不能成为焦点?
UIKit 用 UIFocusItem(焦点项目)表示可聚焦对象,用 UIFocusEnvironment(焦点环境)表示层级关系。UIView 同时符合这两个协议,因为视图自己可以被聚焦,也可以包含子视图。UIViewController 只符合焦点环境,因为它本身没有可见外观。
开发者需要做的第一步,是告诉 UIKit 哪些内容值得进入键盘导航。
焦点外观要贴合内容
键盘焦点必须可见。系统提供 Halo Effect(光环效果),Mac Catalyst 默认使用它。iPadOS 上可以通过 UIFocusHaloEffect 显式设置。
对于全不透明图片,光环很合适。对于普通单元格,焦点状态更像选中高亮。iOS 14 引入的 background configuration 和 content configuration 可以自动得到这种单元格高亮外观。
如果系统样式不合适,开发者可以关闭 focusEffect,在焦点更新回调里自己改颜色、边框或内容状态。
Tab 循环需要结构信息
方向键只在当前区域内移动。Tab 负责在区域之间移动。
UIKit 把这些区域称为 focus groups(焦点组)。一个搜索框、一个侧边栏列表、一个右侧内容列表,可以分别是三个焦点组。Tab 会在每个焦点组的 primary item(主项目)之间移动。
系统会从视图层级推断焦点组。自定义容器视图可能需要手动声明 focusGroupIdentifier,让 Tab 顺序符合用户看到的布局。
键盘事件会进入响应链
焦点系统和 responder chain(响应链)在 iPadOS 15 中会同步。焦点移动到某个单元格时,UIKit 会尝试把 first responder(第一响应者)移动到这个单元格,或者移动到响应链上第一个可以成为第一响应者的对象。
这带来一个变化:如果单元格实现了键盘命令,并且它正处于焦点状态,命令会先送到这个单元格。开发者需要谨慎调用 becomeFirstResponder,因为它会反向推动焦点变化。
详细内容
让自定义视图成为焦点项(03:01)
canBecomeFocused 是焦点系统判断一个项目是否可聚焦的入口。它是 UIFocusItem 的只读属性。自定义视图可以覆写它并返回 true。
override var canBecomeFocused: Bool { true }
关键点:
override表示覆写父类提供的属性。canBecomeFocused是焦点系统的判断入口。Bool { true }让这个视图始终可以成为焦点。
UIKit 的焦点系统基于两个协议:
UIFocusItem
UIFocusEnvironment
关键点:
UIFocusItem表示可以被聚焦的项目。UIFocusEnvironment表示包含焦点项目的层级环境。UIView同时符合这两个协议。UIViewController只符合UIFocusEnvironment。
如果内容用 Metal 等技术绘制,也可以在自己的对象上实现这两个协议,把非 UIKit 内容接入焦点系统。
让集合视图和表格视图参与键盘导航(04:00)
列表和网格是最常见的键盘导航对象。UIKit 为 UITableView 和 UICollectionView 提供了便利 API。设置 allowsFocus 后,不需要给每个 cell 写子类。
class MyViewController: UICollectionViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.collectionView.allowsFocus = true
}
}
关键点:
MyViewController继承自UICollectionViewController。viewDidLoad()是集合视图控制器完成加载后的配置入口。super.viewDidLoad()保留父类的初始化流程。self.collectionView.allowsFocus = true让集合视图里的 cell 可以获得焦点。
侧边栏里的 allowsFocus 默认就是 true。普通列表和网格需要按需打开。
如果只有部分 cell 应该参与键盘导航,可以在 delegate 中实现 canFocusItemAt。
class MyCollectionViewDelegate: NSObject, UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView,
canFocusItemAt indexPath: IndexPath) -> Bool {
return true
}
}
关键点:
MyCollectionViewDelegate实现UICollectionViewDelegate。collectionView(_:canFocusItemAt:)针对单个indexPath返回焦点权限。return true表示这个位置的 cell 可以被聚焦。- 这里可以按业务规则返回
false,例如跳过占位项或不可操作项。
这些方法只影响没有覆写 canBecomeFocused 的 cell。如果 cell 自己覆写了 canBecomeFocused,它的判断优先。
调试时可以在 LLDB 调用 UIFocusDebugger.checkFocusability(for:)。
po UIFocusDebugger.checkFocusability(for: item)
关键点:
po是 LLDB 打印对象的命令。UIFocusDebugger.checkFocusability(for:)会解释某个项目为什么不能被焦点系统选中。item传入你正在调试的视图或焦点项。
使用 Halo Effect 控制焦点外观(05:48)
Mac Catalyst 默认使用类似 macOS 焦点环的 Halo Effect。iPadOS 上可以给视图的 focusEffect 指定 UIFocusHaloEffect。
let focusEffect = UIFocusHaloEffect(
roundedRect: self.bounds,
cornerRadius: self.layer.cornerRadius,
curve: .continuous
)
self.focusEffect = focusEffect
关键点:
UIFocusHaloEffect创建一个光环焦点效果。roundedRect: self.bounds让光环围绕当前视图边界绘制。cornerRadius: self.layer.cornerRadius让光环圆角匹配视图圆角。curve: .continuous使用连续圆角曲线。self.focusEffect = focusEffect把效果应用到当前焦点项。
有些视图层级更复杂。例如图片上方有徽章,光环应该在图片上方、徽章下方。可以设置 referenceView 和 containerView。
let focusEffect = UIFocusHaloEffect(
roundedRect: self.bounds,
cornerRadius: self.layer.cornerRadius,
curve: .continuous
)
// make sure the effect is added right above the image view
focusEffect.referenceView = self.imageView
// make sure the effect is added to our scroll view
focusEffect.containerView = self.scrollView
self.focusEffect = focusEffect
关键点:
referenceView决定光环在视图层级中的相对位置。self.imageView让光环绘制在图片视图上方。containerView决定光环被添加到哪个父视图。self.scrollView可避免直接父视图裁剪光环内容。- 这两个属性都是可选项,只在系统推断结果不符合预期时设置。
自定义焦点外观(07:43)
系统焦点样式无法覆盖所有设计。要完全自定义外观,先把 focusEffect 设为 nil,再在 didUpdateFocus(in:with:) 中处理进入和离开焦点的状态。
init(frame: CGRect) {
super.init(frame: frame)
self.focusEffect = nil
}
func didUpdateFocus(in context: UIFocusUpdateContext, withAnimationCoordinator coordinator: UIFocusAnimationCoordinator) {
if context.nextFocusedItem == self {
// This view is focused. Customize its appearance.
}
else if context.previouslyFocusedItem == self {
// This view was focused.
}
}
关键点:
init(frame:)是自定义视图的初始化入口。super.init(frame: frame)调用父类初始化。self.focusEffect = nil关闭系统焦点样式。didUpdateFocus(in:withAnimationCoordinator:)在焦点变化时被调用。context.nextFocusedItem == self表示当前视图正在获得焦点。context.previouslyFocusedItem == self表示当前视图刚刚失去焦点。
这个回调会发送给上一个焦点项和下一个焦点项的祖先环境。父视图和视图控制器也会收到它。处理时要检查 nextFocusedItem 或 previouslyFocusedItem 是否与当前环境相关。
让侧边栏选择跟随焦点(09:08)
侧边栏有一个特殊体验:焦点移动到哪个项目,选中状态也跟着移动。UIKit 把它称为 selectionFollowsFocus。
var selectionFollowsFocus: Bool
关键点:
selectionFollowsFocus控制焦点移动时是否同步改变选择。- 适合侧边栏这类改变上下文的列表。
- 不适合会弹窗、push 新页面或触发破坏性动作的 cell。
如果大多数 cell 要跟随焦点,只有少数例外,可以使用 delegate 方法逐项控制。
func collectionView(_ collectionView: UICollectionView, selectionFollowsFocusForItemAt indexPath: IndexPath) -> Bool {
return self.action(for: indexPath).type != .showAlert
}
关键点:
selectionFollowsFocusForItemAt针对单个 cell 返回选择策略。self.action(for: indexPath)取出这个位置对应的动作。.type != .showAlert避免焦点移动时直接弹出对话框。- collection view 自身的
selectionFollowsFocus仍然表达整体意图。
Photos 里的“New Album”就是这类例子。选中它会弹出输入相册名的 alert,所以不适合让焦点移动直接触发选择。
用焦点组定义 Tab 循环(12:12)
iPadOS 和 Mac Catalyst 有两套键盘移动方式。方向键在一个区域内移动。Tab 在区域之间移动。这些区域就是焦点组。
给视图或视图控制器设置相同的 focusGroupIdentifier,它们就属于同一个焦点组。
self.focusGroupIdentifier = "com.myapp.groups.sidebar"
关键点:
focusGroupIdentifier用字符串标识焦点组。- 相同标识符的环境会归入同一组。
- 子环境如果没有自己的组,会继承父环境的组。
- 自定义容器视图可以用它表达视觉结构。
每个焦点组都有一个 primary item。Tab 移动到某个组时,焦点系统会选择这个主项目。开发者可以通过优先级影响选择。
extension UIFocusGroupPriority {
public static let ignored: UIFocusGroupPriority // 0
public static let previouslyFocused: UIFocusGroupPriority // 1000
public static let prioritized: UIFocusGroupPriority // 2000
public static let currentlyFocused: UIFocusGroupPriority // NSIntegerMax
}
关键点:
.ignored是最低优先级。.previouslyFocused表示这个组里上次获得焦点的项目。.prioritized表示系统认为更重要的项目,例如已选中的 cell。.currentlyFocused是当前焦点项,优先级最高。- 开发者不能把项目优先级降到系统提供的优先级以下,只能提高另一个项目的优先级。
可以给某个 call-to-action cell 设置高于 .previouslyFocused 的优先级,让它成为焦点组的主项目。
// Customizing an item’s focus group priority
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = ...
if self.isCallToActionCell(at: indexPath) {
// This cell is not as important as a selected cell but should
// be chosen over the last focused cell in this group.
cell.focusGroupPriority = .previouslyFocused + 10
}
return cell
}
关键点:
cellForItemAt负责返回指定位置的 cell。isCallToActionCell(at:)判断这个位置是否是需要突出展示的项目。cell.focusGroupPriority调整该 cell 在焦点组里的优先级。.previouslyFocused + 10让它高于上次聚焦项目。- 如果已选中 cell 的系统优先级更高,它仍然会优先成为主项目。
调试焦点组结构时,可以使用 UIFocusDebugger.checkFocusGroupTree(for:)。
po UIFocusDebugger.checkFocusGroupTree(for: environment)
关键点:
environment是一个焦点环境,例如视图、视图控制器或焦点系统。- 调试器会打印从这个环境开始的焦点组结构。
- 这适合检查 Tab 循环为什么没有按视觉结构移动。
Xcode 还可以开启焦点循环调试覆盖层。给运行参数加上 -UIFocusLoopDebuggerEnabled YES 后,按住 Option 可以看到 Tab 循环顺序,按住 Option + Control 可以看到焦点组。
处理响应链和键盘命令冲突(19:16)
iPadOS 15 的焦点系统会占用一些键盘命令,例如 Tab 和方向键。如果 App 以前用这些键构建自定义键盘导航,编译到 iOS 15 SDK 后,系统焦点导航会接管。
如果某个键盘命令确实需要优先于系统行为,可以设置 wantsPriorityOverSystemBehavior。
keyCommand.wantsPriorityOverSystemBehavior = true
关键点:
keyCommand是 App 中定义的键盘命令。wantsPriorityOverSystemBehavior请求让该命令优先于系统行为。- 只有确认不会破坏键盘导航时才应该设置为
true。
手动处理按键事件时,要完整实现 press 生命周期,并且对不处理的按键一致调用 super。
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
if (/* check presses of interest */) {
// handle the press
}
else {
super.pressesBegan(presses, with: event)
}
}
关键点:
pressesBegan在按键按下时调用。presses包含本次事件里的按键集合。event是对应的UIPressesEvent。- 条件分支只处理 App 真正关心的按键。
super.pressesBegan(presses, with: event)把其他按键交还给系统和响应链。
响应链和焦点系统会互相同步。调用 becomeFirstResponder 会推动焦点更新。session 建议在更新到 iPadOS 15 时检查这类调用,尤其不要在焦点更新回调里随意调用它。
核心启发
-
做什么:给照片、文件或素材网格加入完整键盘导航。 为什么值得做:
allowsFocus可以让集合视图 cell 被方向键访问,Return 可以直接打开当前项目。 怎么开始:在UICollectionViewController.viewDidLoad()设置collectionView.allowsFocus = true,再用canFocusItemAt跳过占位 cell。 -
做什么:为自定义绘制的画布元素加入键盘焦点。 为什么值得做:焦点系统允许非 UIKit 内容通过
UIFocusItem和UIFocusEnvironment接入。 怎么开始:在表示画布对象的模型或包装视图上实现焦点协议,覆写canBecomeFocused,在didUpdateFocus中更新选中边框。 -
做什么:让侧边栏支持“焦点移动即切换内容”。 为什么值得做:
selectionFollowsFocus适合列表驱动的上下文切换,用户只用方向键就能浏览不同分类。 怎么开始:给侧边栏 collection view 设置selectionFollowsFocus,再用selectionFollowsFocusForItemAt排除会弹 alert 的项目。 -
做什么:为多列生产力 App 调整 Tab 顺序。 为什么值得做:
focusGroupIdentifier可以表达视觉分区,避免 Tab 按简单阅读顺序跳过侧边栏内容。 怎么开始:给侧边栏容器设置一个焦点组标识符,用UIFocusDebugger.checkFocusGroupTree(for:)检查生成的组结构。 -
做什么:清理与系统焦点导航冲突的快捷键。 为什么值得做:iPadOS 15 会让 Tab 和方向键服务于统一键盘导航,旧的自定义命令可能失效。 怎么开始:检查
UIKeyCommand中使用 Tab、方向键的命令,能迁移就改键;确需保留时设置wantsPriorityOverSystemBehavior并验证焦点移动没有被破坏。
关联 Session
- Take your iPad apps to the next level — 介绍 iPadOS 15 的多窗口、键盘快捷键界面和指针增强。
- Direct and reflect focus in SwiftUI — 讲解 SwiftUI 中用
@FocusState和focused()管理焦点。 - What’s new in SwiftUI — 汇总 SwiftUI 2021 年的新 API,包括焦点、键盘交互和搜索。
- Qualities of a great Mac Catalyst app — 讲解把 iPad App 带到 Mac 时的界面、菜单和键盘体验。
评论
GitHub Issues · utterances