SwiftUI 无障碍支持全面指南
Catch up on accessibility in SwiftUI
2024年6月10日
一句话判断
SwiftUI 的无障碍不是”事后补丁”而是”设计输出”——你的 View 代码同时在生成视觉界面和无障碍元素树,理解这个机制才能写出真正好用的 App。
这场 Session 讲了什么
这场 Session 深入讲解了 SwiftUI 的无障碍工作机制。核心观点是:SwiftUI 不是在视觉渲染之上”叠加”无障碍层,而是把无障碍元素作为和视觉渲染平行的首要输出。当你写一个 Toggle,SwiftUI 同时生成了屏幕上的开关控件和一个无障碍元素(包含 label、traits、action)。
Session 用一个海滩旅行 App 作为贯穿案例,演示了 VoiceOver 如何与 SwiftUI 的无障碍元素交互。从基础的内容描述(accessibility label),到元素合并(accessibility element children combine),到自定义交互手势的无障碍适配,再到拖拽操作的完整无障碍方案。整个过程强调一个原则:先用 VoiceOver 跑一遍你的 App,找到问题再针对性修改。
值得深挖的点
SwiftUI 的双输出模型
SwiftUI 的 View 不是只产出视觉内容。当你声明一个视图时,系统至少生成两类输出:渲染到屏幕上的视觉层次,和供 VoiceOver、Voice Control、Switch Control 使用的无障碍元素树。这两者是平行关系,不是依附关系。
这解释了为什么修改视觉样式不会破坏无障碍——当你用 toggleStyle 换了一个完全自定义的 Toggle 外观时,它的无障碍元素仍然保留着正确的 label 和 trait。属性和动作绑定在视图的语义上,不是绑定在具体渲染样式上。这是 SwiftUI 区别于 UIKit 手动管理无障碍的核心优势。
但这个模型也有盲区。纯装饰性的视图(如 Shape)默认不会生成无障碍元素。如果你用一个圆形 Shape 做未读指示器,VoiceOver 会完全跳过它。需要手动加 accessibilityLabel 来创建元素,并且要处理可见性变化——当未读状态消失、Shape 的 opacity 变为 0 时,SwiftUI 会自动隐藏对应的无障碍元素。
accessibilityElement(children: .combine) 的设计哲学
当评论区有多个独立元素(消息文本、未读指示器、收藏按钮、回复按钮)时,VoiceOver 用户需要一个一个滑过,体验很差。.combine 模式把这些元素合并成一个:label 是所有子视图 label 的拼接,按钮变成 custom action。
这个设计的精妙之处在于它改变了导航粒度。合并前,一条评论在 VoiceOver 中是 3-4 个独立元素,100 条评论就是 300-400 个元素。合并后,100 条评论就是 100 个元素,每个元素内部通过 custom action 暴露操作。这和视力正常用户的视觉体验是对齐的——正常用户一眼扫过去看到的是”一条评论”,而不是”一段文字加一个按钮加一个指示器”。
代码片段
为未读指示器添加无障碍标签
// Shape 默认没有无障碍元素,需要手动添加
Circle()
.fill(isUnread ? .blue : .clear)
.opacity(isUnread ? 1 : 0)
.accessibilityLabel(isUnread ? "未读" : "") // opacity 为 0 时自动隐藏
场景:自定义的未读圆点指示器需要被 VoiceOver 识别。坑:如果不加 accessibilityLabel,这个 Circle 对 VoiceOver 完全不可见。
合并评论区的无障碍元素
HStack {
VStack(alignment: .leading) {
Text(comment.message)
if isUnread {
Circle().fill(.blue).accessibilityLabel("未读")
}
}
Spacer()
Button("收藏") { /* 收藏逻辑 */ }
Button("回复") { /* 回复逻辑 */ }
}
.accessibilityElement(children: .combine)
// 合并后:label = "未读,消息内容"
// 收藏和回复变成 custom actions
场景:简化 VoiceOver 在评论列表中的导航体验。坑:合并后子元素的独立 identity 丢失了,如果需要对某个按钮做焦点管理就不适用。
为拖拽操作提供无障碍替代方案
// 拖拽操作对 VoiceOver 用户不可用,需要提供等价的自定义 action
.draggable(item)
.accessibilityDragPoint(.init(x: 50, y: 50), description: "拖拽起点")
.accessibilityDropPoint(.init(x: 200, y: 200), description: "放置位置")
// VoiceOver 用户通过 custom action 完成拖拽
场景:让拖拽操作对辅助技术用户可用。坑:必须同时提供 drag point 和 drop point 的描述,否则 VoiceOver 无法理解操作意图。
最佳实践
新项目: 把无障碍当作和视觉设计同等重要的输出通道来考虑。坚持使用 SwiftUI 的内置控件和样式系统,它们自动提供无障碍支持。自定义控件每写一个,都要问自己”VoiceOver 用户怎么知道这个控件在做什么”。
已有项目: 最快的改进方式是打开 VoiceOver 实际使用你的 App 5 分钟,记下所有让你困惑的地方。常见的快速修复:缺少 accessibilityLabel 的装饰性元素、需要 .combine 的列表项、自定义手势缺少无障碍替代。不需要重写,逐个修复就有明显改善。
还有什么值得关注
- SwiftUI 的自定义手势需要通过
accessibilityActivationPoint或 custom action 暴露给辅助技术。 - Super favorite(二次收藏)这种改变了视觉状态的操作,需要同步更新无障碍 label,否则 VoiceOver 会读到 SF Symbol 的原始名称。
accessibilityAddTraits和accessibilityRemoveTraits可以动态调整元素的交互特征。