Refine accessibility for custom controls
Accessibility & Inclusion 进阶 16m

优化自定控件的辅助功能体验

Refine accessibility for custom controls

2026年6月10日

在 Apple 官方观看视频

一句话判断

自定义控件的辅助功能终于不再是“加个 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 }
    }
}

坑:别把 .adjustableaccessibilityAction 混用在同一个控件上,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”。如果是单轴的(如音量、进度),直接补上 .adjustableaccessibilityAdjustableAction;如果是多轴或画板类的,把隐藏的辅助按钮删掉,换成 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] 参数是灵魂,它防止了用户只是想滑过这个区域时误触发复杂的控件手势。
Accessibility SwiftUI VoiceOver DirectTouch CustomControls