WWDC Quick Look 💓 By SwiftGGTeam
VoiceOver efficiency with custom rotors

VoiceOver efficiency with custom rotors

观看原视频

Highlight

VoiceOver 的 Rotor 让用户用上下滑在选定维度内跳转;自定义 Rotor 让 App 把地图标注、文本警告等内容按自己的类别暴露给屏幕阅读器用户。


核心内容

VoiceOver 用户看不到屏幕时,会通过触摸听取当前位置,再用手势在界面元素之间移动。Rotor 是这个流程里的效率入口:用户用两指旋转选择一个维度,再用上滑或下滑移动到上一项或下一项。系统已经提供字符、单词、行这类通用维度,但复杂 App 经常有自己的内容分类。(00:29

Session 的第一个例子是地图。地图上同时有 Apple Store、公园、桥和其他兴趣点。看屏幕的人能靠图标颜色只关注某一类地点;VoiceOver 用户默认要按界面顺序逐个听完所有标注。Apple 的做法是找出视觉上最重要的类别,为 Apple Store 和 parks 各建一个 custom rotor,并按用户距离排序。(01:19

第二个例子是文本 brochure。内置 Lines rotor 可以逐行阅读,但用户为了找到 park alert,要先听完前面的普通内容。自定义 Alerts rotor 把带有 alert attribute 的文字范围提取出来,用户上下滑时只在这些警告之间移动。这个例子说明 custom rotor 不只适合地图标注,也适合富文本、表单、时间线和其他内容密集界面。(06:13

这场 session 的重点是给 VoiceOver 用户提供和视觉用户等价的浏览路径,而不只是给每个元素增加更多 label。实现上,开发者只需要创建 UIAccessibilityCustomRotor,在 closure 里根据方向返回下一个 UIAccessibilityCustomRotorItemResult,再把 rotor 放进相关 view 的 accessibilityCustomRotors。(09:13

详细内容

把 custom rotors 挂到目标视图

04:04)VoiceOver 聚焦到 map view 时,会读取这个 view 的 accessibilityCustomRotors。地图例子需要两个 rotor:一个浏览 Apple Store,一个浏览 parks。两个 rotor 都复用同一个 factory 方法,只把 POI 类型作为输入。

mapView.accessibilityCustomRotors = [customRotor(for: .stores), customRotor(for: .parks)]

关键点:

  • accessibilityCustomRotors 属于承载内容的 view;VoiceOver 到达该 view 后,用户才会看到这些自定义选项。
  • 数组里可以放多个 rotor,适合地图、图库、文档这类有多种浏览维度的界面。
  • customRotor(for:) 的输入是业务分类,session 里分别是 Apple Store 和 parks。

在 closure 里返回上一个或下一个目标

04:31UIAccessibilityCustomRotor 用本地化名称初始化,并接收一个 closure。closure 的参数包含当前 rotor item 和搜索方向。地图例子先把当前元素转成 MKAnnotationView,再从同类标注数组里计算目标下标。

// Custom map rotors

func customRotor(for poiType: POI) -> UIAccessibilityCustomRotor {
    UIAccessibilityCustomRotor(name: poiType.rotorName) { [unowned self] predicate in
        let currentElement = predicate.currentItem.targetElement as? MKAnnotationView
        let annotations = self.annotationViews(for: poiType)
        let currentIndex = annotations.firstIndex { $0 == currentElement }
        let targetIndex: Int
        switch predicate.searchDirection {
        case .previous:
            targetIndex = (currentIndex ?? 1) - 1
        case .next:
            targetIndex = (currentIndex ?? -1) + 1
        }
        guard 0..<annotations.count ~= targetIndex else { return nil } // Reached boundary
        return UIAccessibilityCustomRotorItemResult(targetElement: annotations[targetIndex],
                                                    targetRange: nil)
    }
}

关键点:

  • predicate.currentItem.targetElement 是 VoiceOver 当前停留的元素,地图里期望它是 MKAnnotationView
  • annotationViews(for:) 返回当前 rotor 对应的同类标注,例如只返回 parks。
  • predicate.searchDirection 区分用户上滑还是下滑,代码据此递减或递增 index。
  • 越界时返回 nil,VoiceOver 会留在当前兴趣点;命中目标时返回 UIAccessibilityCustomRotorItemResult
  • 地图元素不是文本范围,所以 targetRangenil

用 attributed text 做 Alerts rotor

08:07)文本例子把所有 alert 文案标记为自定义 NSAttributedString.Key。Rotor closure 的工作是从当前 UITextRange 出发,根据上滑或下滑决定搜索范围和方向,再找到下一个带该 attribute 的 range。

// Custom text rotor

func customRotor(for attribute: NSAttributedString.Key) -> UIAccessibilityCustomRotor {
    UIAccessibilityCustomRotor(name: attribute.rotorName) { [unowned self] predicate in
        var targetRange: UITextRange? // Goal: find the range of following `attribute`
        let beginningRange = self.textRange(from: self.beginningOfDocument,
                                            to: self.beginningOfDocument)
        guard let currentRange = predicate.currentItem.targetRange ?? beginningRange else {
            return nil
        }
        let searchRange: NSRange, searchOptions: NSAttributedString.EnumerationOptions
        switch predicate.searchDirection {
        case .previous:
            searchRange = self.rangeOfAttributedTextBefore(currentRange)
            searchOptions = [.reverse]
        case .next:
            searchRange = self.rangeOfAttributedTextAfter(currentRange)
            searchOptions = []
        }
        self.attributedText.enumerateAttribute(
            attribute, in: searchRange, options: searchOptions) { value, range, stop in
            guard value != nil else { return }
            targetRange = self.textRange(from: range)
            stop.pointee = true
        }
        return UIAccessibilityCustomRotorItemResult(targetElement: self,
                                                    targetRange: targetRange)
    }
}

关键点:

  • beginningRange 给第一次进入 rotor 的用户一个起点,避免当前 item 没有 targetRange 时无法搜索。
  • targetRange 的类型必须是 UITextRange;transcript 特别提醒,文本 rotor 的 targetElement 也要符合 UITextInput
  • .previous 使用反向枚举,.next 使用正向枚举,这样第一处命中就是用户要去的上一条或下一条 alert。
  • enumerateAttribute 命中非空 attribute 后,把 NSRange 转成 UITextRange 并停止继续搜索。
  • 没有命中时 targetRange 保持 nil,VoiceOver 会把它当作到达边界处理。

核心启发

  • 给地图和门店搜索页增加类别 Rotor。做什么:让 VoiceOver 用户在“门店”“停车点”“卫生间”“出入口”等类别之间选择,然后只浏览该类别。为什么值得做:session 的地图 demo 显示,视觉用户靠图标颜色完成的筛选,VoiceOver 用户需要一个等价的导航维度。怎么开始:为每个 POI 类别维护排序后的 accessibility element 数组,并把对应 rotor 放到 map view 的 accessibilityCustomRotors

  • 为长文本增加警告或重点 Rotor。做什么:在协议、景区说明、医疗报告、课程材料里标记 warnings、deadlines、errors 或 action items。为什么值得做:brochure demo 里,Alerts rotor 让用户直接听到“Picnic area closed”和天气警告。怎么开始:用 attributed string 给目标文本打 attribute,在 rotor closure 里枚举该 attribute 并返回 targetRange

  • 把视觉筛选条件转成 VoiceOver 浏览维度。做什么:审查界面里依赖颜色、图标、位置或密度表达的类别,把真正影响任务完成的类别做成 rotor。为什么值得做:transcript 提醒开发者先找出“视觉上吸引注意”的元素,再按类别分组。怎么开始:让设计和无障碍测试一起列出 2-3 个高价值维度,先在最复杂的一屏实现。

  • 在 QA 中加入 Rotor 路径测试。做什么:打开 VoiceOver,从复杂页面入口开始,用 rotor 完成一次核心任务。为什么值得做:session 结尾要求开发者找出视觉复杂区域,并确认 VoiceOver 是否能同样容易到达内容。怎么开始:为地图、富文本、表格或日程视图写一份人工检查清单,记录每个 rotor 的名称、顺序和边界行为。

关联 Session

评论

GitHub Issues · utterances