WWDC Quick Look 💓 By SwiftGGTeam
Support Full Keyboard Access in your iOS app

Support Full Keyboard Access in your iOS app

观看原视频

Highlight

iOS 的 Full Keyboard Access 让用户只用键盘操作 iPhone 和 iPad,开发者可以通过自定义操作、键盘快捷键、交互性标记、输入标签和无障碍路径让应用适配键盘用户。

核心内容

有些用户不能稳定地触摸屏幕。手指活动范围有限、手部震颤、精细操作困难,都会让点按、长按、拖拽变成负担。

iOS 已经提供 AssistiveTouch(辅助触控)、Switch Control(切换控制)和 Voice Control(语音控制)。Full Keyboard Access(全键盘访问)补上了另一种输入方式:用户可以只用外接键盘完成系统导航、元素选择、操作菜单、查找和手势模式。

这件事对 iPad 尤其重要。用户可以用方向键移动,用空格激活,用 Tab 进入不同区域,用 Tab-Z 打开操作菜单,用 Tab-F 查找界面元素。键盘从辅助输入变成完整输入路径。

开发者遇到的问题会出现在细节里。按钮能被 Tab 选中,但按空格没有反应;卡片支持长按置顶,但键盘用户找不到这个动作;设置按钮只有齿轮图标,用户不知道该搜索 “settings” 还是 “prefs”。

Apple 在这场 session 里用一个叫 Shape Shuffle 的小游戏演示修复过程。核心做法是补充已有控件的无障碍信息:这个元素有什么动作、什么时候能响应、用户还可能用哪些词找到它、焦点轮廓应该是什么形状。

详细内容

给常用动作添加 Accessibility Custom Action

07:06)Shape Shuffle 里的卡片支持两个动作:加入棋盘和加 pin。触摸用户可以双击或长按,键盘用户需要在 Tab-Z 操作菜单里看到同样的动作。

UIKit 的入口是 UIAccessibilityCustomAction。把动作挂到 accessibilityCustomActions 后,VoiceOver、Switch Control 和 Full Keyboard Access 都能发现它。

// Accessibility custom actions

let addAction = UIAccessibilityCustomAction(
    name: gameLocString("add"), image: UIImage(systemName: "plus.square")) { _ in
        self.addCard()
        return true
    }

let pinAction = UIAccessibilityCustomAction(
    name: gameLocString("pin"), image: UIImage(systemName: "pin.fill")) { _ in
        self.pinCard()
        return true
    }

cardView.accessibilityCustomActions = [addAction, pinAction]

关键点:

  • UIAccessibilityCustomAction 定义一个辅助技术可调用的动作。
  • name 使用本地化字符串,操作菜单会显示这段文字。
  • image 对 Full Keyboard Access 不显示,但会在 Switch Control 中使用。
  • 闭包里调用原有业务方法 addCard()pinCard(),成功后返回 true
  • cardView.accessibilityCustomActions 把两个动作绑定到当前卡片。

给键盘用户提供快捷键

08:39)自定义操作适合辅助技术用户。键盘快捷键覆盖面更广:开启 Full Keyboard Access 的用户能用,普通外接键盘用户也能用。

iOS 和 iPadOS 15 把 Mac Catalyst 13 的菜单构建 API 带到 iOS。开发者可以在 AppDelegate 里覆盖 buildMenu(with:),创建 UIKeyCommand,再插入 UIMenuBuilder

// Keyboard shortcuts

// In AppDelegate.swift
override func buildMenu(with builder: UIMenuBuilder) {
    super.buildMenu(with: builder)
    guard builder.system == .main else {
        return
    }

    let pinCommand = UIKeyCommand(
        title: gameLocString("pin"),
        image: UIImage(systemName: "pin.fill"),
        action: #selector(GameViewController.pinFocusedCard),
        input: "P",
        discoverabilityTitle: gameLocString("pin.card")
    )

    let addCommand = UIKeyCommand(
        title: gameLocString("add"),
        image: UIImage(systemName: "plus.square"),
        action: #selector(GameViewController.addFocusedCard),
        input: "A",
        discoverabilityTitle: gameLocString("add.card")
    )

    let identifier = UIMenu.Identifier("gameplay_menu")
    let menu = UIMenu(
        title: gameLocString("gameplay"),
        image: UIImage(systemName: "rectangle.grid.3x2"),
        identifier: identifier,
        children: [addCommand, pinCommand]
    )

    builder.insertSibling(menu, afterMenu: .view)
}

关键点:

  • buildMenu(with:) 是向系统菜单加入命令的位置。
  • guard builder.system == .main 限制这段逻辑只作用于主菜单。
  • UIKeyCommand 把标题、图标、selector 和按键绑定在一起。
  • input: "P"input: "A" 分别定义 pin 和 add 的快捷键。
  • discoverabilityTitle 会出现在按住 Command 后显示的快捷键面板里。
  • UIMenu 把两个命令归入 “gameplay” 菜单。
  • builder.insertSibling(menu, afterMenu: .view) 把菜单插到 View 菜单之后。

只在有焦点卡片时启用命令

09:22)快捷键不能永远可用。Shape Shuffle 只有在选中卡片后,add 和 pin 才有意义。

UIKit 用 responder chain(响应者链)决定 action 是否可执行。覆盖 canPerformAction(_:withSender:),可以让快捷键面板只在正确状态下显示命令。

// Keyboard shortcuts

// In GameViewController.swift
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
    if action == #selector(addFocusedCard) || action == #selector(pinFocusedCard) {
        return self.focusedCard != .none
    }
    return super.canPerformAction(action, withSender: sender)
}

关键点:

  • canPerformAction 决定某个 selector 当前是否可执行。
  • #selector(addFocusedCard) 对应上一个示例里的 add 快捷键。
  • #selector(pinFocusedCard) 对应 pin 快捷键。
  • self.focusedCard != .none 确保用户已经选中一张卡片。
  • 其他 action 交给 super,避免影响系统菜单和其他控制器。

让焦点跳过不能交互的元素

10:35)开发者为 VoiceOver 做过适配后,经常会把很多视图标成无障碍元素。Shape Shuffle 的三角形、颜色和数字需要被 VoiceOver 读出来,所以代码会设置 isAccessibilityElementaccessibilityLabel

itemView.isAccessibilityElement = true
itemView.accessibilityLabel = gameLocString(for: item)

关键点:

  • isAccessibilityElement = trueitemView 进入无障碍树。
  • accessibilityLabel 提供 VoiceOver 读取的文本。
  • Full Keyboard Access 的光标也可能访问这些无障碍元素。
  • 如果元素只能被读取,不能被操作,键盘用户会遇到“选中了但按空格没反应”的假焦点。

11:01)解决办法是设置 accessibilityRespondsToUserInteraction。当它为 false 时,元素仍然可被 VoiceOver 读取,但 Full Keyboard Access 和 Switch Control 这类运动辅助技术会跳过它。

itemView.accessibilityRespondsToUserInteraction = false

关键点:

  • 这个属性只在 isAccessibilityElement 已经为 true 时有意义。
  • 系统默认会尝试判断一个无障碍元素是否可交互。
  • 当一个元素服务于 VoiceOver 读取,但不应该接受键盘或切换控制焦点时,手动设为 false
  • 不要为了 Full Keyboard Access 去覆盖 canBecomeFocused,因为它会影响整个焦点引擎,也会影响未开启 Full Keyboard Access 的 Tab 导航。

给查找功能提供更多输入标签

13:41)Full Keyboard Access 有 Find(查找)功能。用户按 Tab-F 后,可以输入文字快速跳到界面元素。

问题出在图标按钮上。设置按钮可能叫 “Settings”,用户也可能输入 “prefs”、“preferences” 或 “gear”。accessibilityUserInputLabels 允许开发者给同一个控件提供多组可输入名称。

self.accessibilityUserInputLabels = [
    gameLocString("settings"),
    gameLocString("prefs"),
    gameLocString("preferences"),
    gameLocString("gear")
]

关键点:

  • accessibilityUserInputLabels 是字符串数组。
  • 每个字符串都应该本地化,和界面语言保持一致。
  • Full Keyboard Access 的 Find 会使用这些字符串匹配元素。
  • Voice Control 也会使用这些名称,用户可以说出任意一个名称来触发控件。
  • 这不会替换 VoiceOver 的 accessibilityLabel

用 Accessibility Path 改进焦点轮廓

14:52)Full Keyboard Access 的光标默认用矩形包围控件。Shape Shuffle 的关卡按钮是圆形、三角形和方形,矩形光标不贴合视觉形状。

accessibilityPath 可以告诉系统元素的真实形状。这个 API 同样会被 VoiceOver 使用。

// Accessibility path

let rect = circleLevelButton.convert(levelButton.bounds, to: nil)

circleLevelButton.accessibilityPath = UIBezierPath(ovalIn: rect)

关键点:

  • convert(_:to:) 把按钮 bounds 转成屏幕坐标。
  • accessibilityPath 需要使用屏幕坐标。
  • UIBezierPath(ovalIn:) 创建圆形或椭圆形路径。
  • 设置后,Full Keyboard Access 的光标会贴合圆形按钮。

如果按钮在 UIScrollView 里,滚动会改变屏幕坐标。Apple 建议覆盖 accessibilityPathaccessibilityFrame,每次读取时重新计算。

// If your button is in a scroll view, it’s generally better to
// override accessibilityPath and/or accessibilityFrame
extension CircleButton {
    open override var accessibilityPath: UIBezierPath? {
        get {
            let rect = self.convert(self.bounds, to: nil)
            return UIBezierPath(ovalIn: rect)
        }
        set {
            // no-op
        }
    }
}

关键点:

  • 扩展 CircleButton,覆盖继承自 UIViewaccessibilityPath
  • getter 里每次用当前 bounds 重新计算屏幕坐标。
  • 返回 UIBezierPath(ovalIn:),让系统得到最新的圆形路径。
  • setter 留空,防止外部写入破坏动态计算结果。

核心启发

  • 做什么:给卡片、邮件、任务、照片等列表项增加键盘操作菜单。 为什么值得做UIAccessibilityCustomAction 会同时服务 Full Keyboard Access、VoiceOver 和 Switch Control,用户不用触屏也能执行收藏、归档、删除。 怎么开始:为每个列表项创建 UIAccessibilityCustomAction,把业务方法放进 action 闭包,再赋值给 accessibilityCustomActions

  • 做什么:为高频操作设计 iPad 键盘快捷键。 为什么值得做UIKeyCommand 让开启 Full Keyboard Access 的用户和普通键盘用户使用同一套快捷操作。 怎么开始:在 buildMenu(with:) 里创建 UIKeyCommand,给每个命令补上 discoverabilityTitle,用 canPerformAction 控制可用状态。

  • 做什么:清理“假焦点”。 为什么值得做:键盘光标落到不能交互的元素上,会让用户误以为应用卡住。 怎么开始:打开 Full Keyboard Access,用 Tab 遍历界面;发现只用于朗读的元素后,保留 isAccessibilityElement,把 accessibilityRespondsToUserInteraction 设为 false

  • 做什么:给图标按钮增加多组搜索词。 为什么值得做:用户用 Find 或 Voice Control 时,不一定知道开发者给控件取的名字。 怎么开始:为设置、分享、筛选、收藏这类图标按钮设置 accessibilityUserInputLabels,写入常见同义词和口语名称。

  • 做什么:让自定义形状控件的焦点轮廓匹配视觉形状。 为什么值得做accessibilityPath 能让 Full Keyboard Access 和 VoiceOver 使用同一条真实轮廓,减少焦点位置和视觉界面的错位。 怎么开始:用 UIBezierPath 描述控件形状;静态控件直接设置 accessibilityPath,滚动区域中的控件覆盖 getter 动态计算。

关联 Session

评论

GitHub Issues · utterances