Highlight
Mac 应用的无障碍和 iPhone/iPad 有一个关键区别:Mac 的界面更密集,嵌套容器层级更深。VoiceOver 在 Mac 上默认按容器导航,这意味着如果你的 accessibility 元素树结构不合理,用户每走一步都要反复”钻入”和”跳出”容器,体验极其低效。
核心内容
想象一下这样的场景:用户打开你的 Mac 文本编辑器,想去右侧 Format Inspector 里点一下”Bold”开关。视力正常的用户瞄一眼、移一下鼠标就完成了。但 VoiceOver 用户必须按方向键,一个一个穿过 22 个无障碍元素——Title、Apply、Subtitle、Apply、Heading、Apply……才能到达”Bold”。Session 里 Nicholas 录下了这段真实音频,光是听完就让人头皮发麻。
Mac 的根本特征是:键鼠交互、信息密度高、多任务窗口多。这意味着 SwiftUI 默认把每个 view 都当成独立 accessibility element,再加上 VoiceOver 在 Mac 上默认按容器(container)跳转的策略,应用很容易陷入两种极端:要么扁平到每一步都得过一遍所有元素,要么嵌套过深以至于用户在容器之间反复”focus in / focus out”。今年 Apple 给了三件武器来解决这个问题:用 accessibilityElement(children:) 重塑布局结构、用 rotor 与 accessibilityDefaultFocus 加速导航、用 accessibilityAction 给 hover-only 操作补一条键盘等价路径。整场 Session 围绕一个文本编辑器 demo,把这三件事一步步打磨到位。
详细内容
第一步:用 accessibilityElement(children:) 重塑布局(04:15)。这个 modifier 提供三种行为:contain 把子视图聚成一个容器、combine 把子视图合并成一个元素、ignore 直接屏蔽子视图。Demo 里 22 个 style preset 元素被压缩成一个名为 “Style Presets” 的 group:
// Contain style presets in accessibility container
struct FormattingInspectorView: View {
var body: some View {
Form {
VStack {
StylePresetView(type: .title)
StylePresetView(type: .heading)
StylePresetView(type: .subHeading)
StylePresetView(type: .body)
}
.accessibilityElement(children: .contain)
.accessibilityLabel("Style Presets")
}
}
}
关键点:
.accessibilityElement(children: .contain):把 VStack 变成一个容器,子视图依然是独立元素,但 VoiceOver 默认从外层一跳而过。.accessibilityLabel("Style Presets"):给容器一个清晰的名字,用户听到 “Style Presets, Group” 就知道里面是什么。- 改完之后元素总数从 22 降到 15,“Bold” 开关只差几下方向键。
第二步:用 combine 合并成对元素(06:21)。每个 style preset 是 HStack { Title, Button },VoiceOver 会分别读出”Title”和”Apply - button”两次。用 .combine 把它们合成一个元素:
struct StylePresetView: View {
let preset: StylePreset
var body: some View {
HStack {
PresetTitleView(preset: preset)
Button("Apply") { /* ... */ }
}
.accessibilityElement(children: .combine)
}
}
关键点:
.combine会把子视图的 label、traits、actions 全部合并到一个 element 上。- VoiceOver 一次性读出”Title, Apply, button”,操作语义清晰。
- 元素数量再次减半,从 8 个降到 4 个。
第三步:用 rotor 把”找书签”做成一键跳转(08:43)。视力正常的人扫一眼侧栏就能看出哪些页面被加了书签;VoiceOver 用户却要逐页听过去。Rotor 让用户用一个键盘快捷键弹出菜单,里面只列出符合条件的元素:
struct PagesView: View {
@Binding var pages: [Page]
var body: some View {
List(pages) { page in
PageListItemView(page: page)
}
.accessibilityRotor("Bookmarks") {
ForEach(pages) { page in
if page.isBookmarked {
AccessibilityRotorEntry(page.title, id: page.id)
}
}
}
}
}
关键点:
.accessibilityRotor("Bookmarks"):注册一个名为 “Bookmarks” 的转轮。AccessibilityRotorEntry(page.title, id: page.id):每个条目对应一个可跳转目标,id必须能在 List 中定位到对应行。- VoiceOver 在 List 上聚焦后,用 rotor 快捷键弹出菜单,直接跳到 “Page 5 bookmarked”。
第四步:用 accessibilityDefaultFocus 建议初始焦点(09:33)。macOS / iOS 26 新增的 modifier,允许应用在新场景出现时建议 VoiceOver 把焦点放到哪个视图上:
struct MyView: View {
@AccessibilityFocusState(for: .voiceOver) var focusedForVoiceOver
var body: some View {
FirstView()
SecondView()
.accessibilityDefaultFocus($focusedForVoiceOver, true)
ThirdView()
}
}
关键点:
@AccessibilityFocusState(for: .voiceOver):只针对 VoiceOver 的焦点状态,不影响键盘焦点。.accessibilityDefaultFocus($focusedForVoiceOver, true):场景出现时建议 SecondView 获得焦点。- 仅是”建议”——VoiceOver 可能根据用户偏好做出不同选择,应用不要强假设。
第五步:给 hover-only 操作补 accessibilityAction(10:28)。Demo 里的书签按钮只在鼠标悬停页面缩略图时才出现。VoiceOver 用户没有悬停动作,按钮永远摸不到:
struct PageListItemView: View {
var page: Page
var body: some View {
VStack() {
ThumbnailView(page: page)
Text(page.title)
}
.onHover { /* ... */ }
.accessibilityAction(named: page.isBookmarked ? "Remove Bookmark" : "Bookmark") {
page.isBookmarked.toggle()
}
}
}
关键点:
.accessibilityAction(named:):声明一个有名字的动作,VoiceOver / Voice Control / Switch Control 都能调用。- 名字根据当前状态动态变化(“Bookmark” / “Remove Bookmark”),用户清楚下一步会发生什么。
- 这条 action 不依赖鼠标悬停,键盘和辅助技术都能直接触达。
核心启发
-
做什么:在项目早期把 VoiceOver 当成回归测试跑一遍。
- 为什么值得做:Session 中 Nicholas 反复强调”从一开始测”,问题越早暴露成本越低。视觉布局阶段就听一遍 VoiceOver 朗读,能立刻发现元素过多、顺序错乱、容器嵌套过深这类结构问题。
- 怎么开始:在 macOS 上用
Cmd + F5打开 VoiceOver,用Ctrl + Option + 方向键在新写的 view 上走一遍,把”听完一个完整页面要几次按键”作为团队的可观察指标。
-
做什么:用
accessibilityElement(children:)给密集界面分层。- 为什么值得做:Mac 应用的 inspector、sidebar、toolbar 普遍包含大量并列控件,扁平结构会让 VoiceOver 用户付出几十次按键才能穿过。
contain+accessibilityLabel几行代码就能把元素总数降一半。 - 怎么开始:先在最外层 group 用
.contain配合一个有意义的.accessibilityLabel,再到内层把语义成对的子视图(Title + Button、Icon + Label)用.combine合并;嵌套层级控制在 3 层以内。
- 为什么值得做:Mac 应用的 inspector、sidebar、toolbar 普遍包含大量并列控件,扁平结构会让 VoiceOver 用户付出几十次按键才能穿过。
-
做什么:为重要集合加 rotor。
- 为什么值得做:书签、错误项、待办、未读消息这些”散落在长列表里的少数子集”,是 VoiceOver 用户最容易迷路的地方。Rotor 把 O(N) 的扫描变成 O(1) 的菜单跳转。
- 怎么开始:找出应用里”用户最常想直接跳过去”的元素类型,给对应的 List / ScrollView 加
.accessibilityRotor("名字") { ForEach { AccessibilityRotorEntry(...) } },再用 VoiceOver 测一下快捷键弹出菜单是否能跳到正确的行。
-
做什么:审计所有 hover-only 与手势操作,补
accessibilityAction。- 为什么值得做:Mac 里很多”鼠标悬停才出现的按钮”对 VoiceOver、Switch Control、Voice Control 用户都是隐形控件。一个
.accessibilityAction(named:)就能让所有辅助技术接入。 - 怎么开始:grep 项目里的
.onHover与.gesture调用点,逐一确认是否触发了状态变更或导航;如果是,就在同一个 view 上加.accessibilityAction(named:),名字写成动词短语。
- 为什么值得做:Mac 里很多”鼠标悬停才出现的按钮”对 VoiceOver、Switch Control、Voice Control 用户都是隐形控件。一个
关联 Session
- Customize your app for Assistive Access — 把应用裁剪成 Assistive Access 模式,让认知障碍用户也能上手
- Evaluate your app for Accessibility Nutrition Labels — App Store 新的无障碍营养标签,先自评再发布
- Principles of inclusive app design — 从理解残障开始,重塑应用设计原则
- Catch up on accessibility in SwiftUI — 本场前置课,讲清 accessibility element 的基本模型
评论
GitHub Issues · utterances