Tailor the VoiceOver experience in your data-rich apps
Accessibility 高级 22m

为数据密集型 App 定制 VoiceOver 体验

Tailor the VoiceOver experience in your data-rich apps

2021年6月11日

在 Apple 官方观看视频

一句话判断

如果你的 app 有表格、图表、列表等数据密集界面,VoiceOver 默认的朗读方式会让视障用户完全迷失 —— 这集 Session 教你如何用自定义 rotor、announcement 通知和智能分组来重建数据的信息层次。

这场 Session 讲了什么

Session 聚焦于数据密集型 app(如股票追踪、健身记录、任务管理)的 VoiceOver 优化策略。当界面中有大量数字、状态、图表时,VoiceOver 默认的”逐个朗读所有元素”方式效率极低,甚至让用户无法理解数据含义。

核心策略:

  1. 自定义 Rotor:添加自定义的筛选维度(如”按状态筛选”、“按优先级跳转”),让 VoiceOver 用户可以快速在特定类型的数据间跳转。
  2. 动态 Announcement:在数据更新时主动通知 VoiceOver 用户,而不是等他们手动刷新。
  3. Smart Grouping(智能分组):把相关的数据点组织成有意义的单元,减少 VoiceOver 的停留次数。
  4. Custom Actions:为每个数据项提供上下文相关的操作列表(如”标记完成”、“删除”、“编辑”),通过上下滑动选择。

值得深挖的点

自定义 Rotor 的实现。Rotor 是 VoiceOver 的一个导航维度,默认只有”字符”、“词”、“标题”等。你可以添加自定义 rotor 选项(如”按项目状态跳转”),让用户在转子菜单中选择后,上下滑动只在匹配的项目间跳转。实现方式是创建 AccessibilityCustomContentUIAccessibilityCustomRotor,过滤和排序由你决定。

数据更新的无障碍通知。当数据实时变化时(如股票价格波动、新消息到达),你需要主动通知 VoiceOver 用户。使用 UIAccessibility.post(notification: .announcement, argument: "苹果股价上涨 2%") 发送语音通知。但要控制频率 —— 如果股价每秒变化都播报,用户会被淹没。建议只在关键变化(涨跌幅超过阈值)时发送。

代码片段

SwiftUI 自定义 Rotor

import SwiftUI

struct TaskListView: View {
    let tasks: [Task]
    @AccessibilityFocusState private var focusedTaskId: UUID?

    var body: some View {
        List(tasks) { task in
            TaskRowView(task: task)
                .accessibilityElement(children: .combine)
                .accessibilityLabel(taskDescription(task))
                .accessibilityAddTraits(.isButton)
                .accessibilityFocused($focusedTaskId, equals: task.id)
                // 自定义操作
                .accessibilityAction(named: "标记完成") {
                    markComplete(task)
                }
                .accessibilityAction(named: "删除") {
                    deleteTask(task)
                }
        }
        // 自定义 Rotor:按优先级跳转
        .accessibilityRotor("高优先级任务") {
            ForEach(tasks.filter { $0.priority == .high }) { task in
                AccessibilityRotorEntry(task.title, id: task.id) {
                    focusedTaskId = task.id
                }
            }
        }
        .accessibilityRotor("今日截止") {
            ForEach(tasks.filter { Calendar.current.isDateInToday($0.dueDate) }) { task in
                AccessibilityRotorEntry(task.title, id: task.id) {
                    focusedTaskId = task.id
                }
            }
        }
    }

    private func taskDescription(_ task: Task) -> String {
        let priorityText = task.priority == .high ? "高优先级" : "普通"
        let statusText = task.isComplete ? "已完成" : "未完成"
        return "\(task.title), \(priorityText), \(statusText), 截止 \(task.dueDate.formatted(date: .abbreviated, time: .shortened))"
    }
}

数据更新的无障碍通知

import SwiftUI

class StockViewModel: ObservableObject {
    @Published var stockPrice: Double = 0
    private var lastAnnouncedPrice: Double = 0

    func updatePrice(_ newPrice: Double) {
        let oldPrice = stockPrice
        stockPrice = newPrice

        // 只在价格变化超过 1% 时通知 VoiceOver
        let changePercent = abs(newPrice - oldPrice) / oldPrice * 100
        if changePercent > 1.0 {
            let direction = newPrice > oldPrice ? "上涨" : "下跌"
            let message = "苹果股价\(direction)\(String(format: "%.2f", newPrice)) 美元"

            // 发送 VoiceOver 通知
            UIAccessibility.post(notification: .announcement, argument: message)
            lastAnnouncedPrice = newPrice
        }
    }
}

数据表格的无障碍分组

import SwiftUI

struct DataTableRow: View {
    let entry: DataEntry

    var body: some View {
        HStack {
            Text(entry.name).frame(width: 120)
            Text("\(entry.value, specifier: "%.1f")").frame(width: 80)
            Text(entry.status.rawValue).frame(width: 80)
            Text(entry.date.formatted(date: .abbreviated, time: .omitted))
        }
        // 把一行数据组合为一个 VoiceOver 元素
        .accessibilityElement(children: .combine)
        .accessibilityLabel {
            // 按重要性排序描述
            "\(entry.name), \(entry.status.rawValue), 数值 \(entry.value, specifier: "%.1f"), \(entry.date.formatted(date: .abbreviated, time: .omitted))"
        }
    }
}

最佳实践

  1. 数据朗读要有人类友好的措辞。不要读”1,523.50”,要读”一千五百二十三点五零美元”。NumberFormatter.spellOut 风格在 VoiceOver 场景中更合适。
  2. Rotor 维度不要超过 5 个。太多选项让 VoiceOver 用户在转子菜单中迷失。选择最常用的筛选维度即可。
  3. 实时数据通知的频率要有策略。不是所有数据变化都值得播报。用阈值过滤噪音,只在用户真正关心的变化时通知。

还有什么值得关注

  • AccessibilityCustomContent 可以添加”详细信息”层,用户通过 Rotor 切换到”详细信息”时才听到额外数据。
  • AXCustomContentDataProvider 协议支持动态生成无障碍内容。
  • Xcode 13 的 Accessibility Inspector 可以模拟 Rotor 操作,在开发阶段验证自定义导航是否正确。
WWDC 2021