优化自定控件的辅助功能体验
Refine accessibility for custom controls
2026年6月10日
一句话判断
自定义控件的辅助功能终于不再是“加个 Label 糊弄过去”,Apple 用 Direct Touch API 和 Custom Actions 给出了 2D 空间和复杂手势的官方标准解法。
这场 Session 讲了什么
这场 Session 最值得关注的一件事是,Apple 终于把自定义控件的 Accessibility (辅助功能) 从单纯的“文本朗读”升级到了“空间与手势交互”。以前我们手搓了一个酷炫的 2D 触控板或者捏合缩放控件,给 VoiceOver (旁白) 用户用的时候只能塞个隐藏按钮或者拆成四个方向键,体验极其割裂。现在苹果直接把底层的交互逻辑和事件分发开放了。
从视觉隐喻到 AT (Assistive Technology,辅助技术) 语义的翻译,是这次分享的灵魂。讲师 Khin 拿咖啡机滑块和 2D 均衡器举例,把 .adjustable 的单轴局限、Pass-through gestures (穿透手势) 的精细控制,以及 Direct Touch API (直接触控 API) 的全局接管讲得非常透。这对那些喜欢在 SwiftUI 里搞创意交互、做复杂自定义 UI 的开发者影响最大,以后再也不用为了无障碍去妥协 UI 设计了。
值得深挖的点
2D 控件的破局:Custom Actions 取代“伪滑块”
以前遇到 2D 触控板(比如视频里的 EQ 均衡器,X 轴频率 Y 轴振幅),很多开发者的做法是放两个隐藏的 Slider,或者用四个按钮代表上下左右。这种做法在 VoiceOver 里焦点跳跃极不自然。Apple 这次推的 accessibilityAction (自定义操作) 是正解。它允许你在同一个焦点下挂载多个动作(Move Up/Down/Left/Right),用户通过上下滑动转子 (Rotor) 来选择动作,双击执行。
Trade-off 在于,它牺牲了 .adjustable 那种“无脑上下滑动就能调”的直觉感,换来了多维度的精确控制。好的一面是,VoiceOver 用户本来就被训练过使用转子菜单,学习成本为零;坑在于,如果你把动作拆得太细(比如每次只移动 1 个像素),用户会滑到怀疑人生。Apple 建议用固定步长(比如视频里的 10),并在 Value 里实时播报当前坐标。
Direct Touch API:让 VoiceOver 用户也能“盘”虚拟猫
视频后半段那个“撸猫”控件简直是神来之笔。猫需要被抚摸(滑动)、戳(点击)、捏(Pinch)。如果开着 VoiceOver,全局的滑动和捏合手势会被系统截获,控件根本收不到事件。accessibilityDirectTouch (直接触控) 就是为这种“强手势依赖”场景准备的。
开启 [.requiresActivation] 后,VoiceOver 用户双击并按住屏幕,就会进入 Direct Touch 模式,此时系统把手势事件直接透传给控件。这比以前的 Passthrough (穿透) 更彻底。Apple 做这个的意图很明显:不要让残障用户只能玩“点点点”的无聊游戏,他们也需要丰富的物理反馈。实战中最大的坑是“状态迷失”,用户进入 Direct Touch 模式后如果不小心松手,会退回 VoiceOver 模式,如果没有做好 Haptic (触觉反馈) 和 Audio (音频) 提示,用户会完全不知道当前处于什么状态。
代码片段
1. 穿透手势与防抖播报
场景:咖啡机滑块,需要支持 0.5 盎司的精细调节,同时避免 VoiceOver 疯狂朗读。
struct CoffeeDispenserView: View {
@State var coffee: Double = 0.0
@State private var lastSpokenTime: Date = .now
var body: some View {
CoffeeSlider(value: $coffee)
.accessibilityElement()
.accessibilityLabel("咖啡分配器")
.accessibilityValue("\(coffee, specifier: "%.1f") 盎司")
// 把激活点设置在当前液面高度,方便双向滑动
.accessibilityActivationPoint(UnitPoint(x: 0.5, y: 1 - coffee))
.onChange(of: coffee) { _, newValue in
// 防抖:至少间隔 0.3 秒且值变化才播报
if Date.now.timeIntervalSince(lastSpokenTime) > 0.3 {
lastSpokenTime = .now
AccessibilityNotification.Announcement("\(newValue) 盎司").post()
}
}
}
}
坑:accessibilityActivationPoint 必须跟着当前值动态变,否则用户从底部往上滑时,手指还没到屏幕一半就触发最大值了。
2. 用 Custom Actions 实现 2D 空间导航
场景:EQ 均衡器,在 2D 平面上移动控制点。
struct EqualizerView: View {
@State var x: Double = 0
@State var y: Double = 0
var body: some View {
EqualizerPad(x: $x, y: $y)
.accessibilityElement()
.accessibilityLabel("均衡器触控板")
.accessibilityValue("频率 \(Int(x)), 振幅 \(Int(y))")
// 挂载四个方向的自定义操作
.accessibilityAction(named: "向上移动") { y += 10 }
.accessibilityAction(named: "向右移动") { x += 10 }
.accessibilityAction(named: "向下移动") { y -= 10 }
.accessibilityAction(named: "向左移动") { x -= 10 }
}
}
坑:别把 .adjustable 和 accessibilityAction 混用在同一个控件上,VoiceOver 的转子逻辑会冲突,优先用 Action 处理多维场景。
3. Direct Touch 接管复杂手势
场景:虚拟宠物,需要支持滑动抚摸和双指捏合。
struct VirtualCat: View {
@State var catMood: String = "平静"
var body: some View {
InteractiveCatSurface(mood: $catMood)
.accessibilityLabel("虚拟猫")
.accessibilityValue(catMood)
// 要求用户双击并按住来激活直接触控模式
.accessibilityDirectTouch([.requiresActivation])
}
}
坑:进入 Direct Touch 模式时,一定要在 UI 上给个明显的视觉或声音提示(比如猫叫一声),告诉用户“现在你的手势直接作用于猫了,而不是在操作 VoiceOver”。
最佳实践
- 已有项目的迁移策略:先排查项目里那些“为了好看而手搓的 UI”。如果是单轴的(如音量、进度),直接补上
.adjustable和accessibilityAdjustableAction;如果是多轴或画板类的,把隐藏的辅助按钮删掉,换成accessibilityAction。 - 新项目的采用建议:在设计自定义控件的第一天,就把 Accessibility 语义写进 API 设计里。不要等 UI 画完了再想怎么“加”无障碍,而是让控件本身就“是”无障碍的。比如设计一个 2D 摇杆,它的 Value 就应该返回极坐标或 XY 值,而不是只返回一个状态。
- 实战避坑:播报 (Announcement) 绝对不能滥用。像视频里提到的 0.3 秒防抖阈值是个很好的基准线。如果用户滑动很快,一秒钟播报 10 次“50%、51%、52%”会让 VoiceOver 用户直接关掉 App。
还有什么值得关注
- 动态激活点 (Dynamic Activation Point):
accessibilityActivationPoint可以绑定状态,让穿透手势的起点永远跟随当前控件的实际物理位置,这个细节极其提升体验。 - 转子 (Rotor) 的无缝集成:Custom Actions 会自动注册到 VoiceOver 的转子菜单中,不需要开发者手动去写复杂的 UIAccessibilityCustomRotor 代码了。
- Direct Touch 的按需激活:
[.requiresActivation]参数是灵魂,它防止了用户只是想滑过这个区域时误触发复杂的控件手势。