Highlight
Apple 为 SwiftUI 和 UIKit 新增了 AccessibilityNotification 公告优先级、Toggle trait、Zoom Action、Direct Touch 和 UIKit 属性自动同步等无障碍 API,让 VoiceOver 交互更精准、公告不再互相干扰,同时减少开发者手动维护无障碍状态的代码量。
核心内容
问题:自定义控件对 VoiceOver 用户不透明
想象一个照片编辑 App,界面上有一个滤镜切换按钮。对视力正常的用户来说,按钮背景色从浅绿变深绿,一眼就知道滤镜开了还是关了。但对 VoiceOver 用户来说,系统只会说”按钮”,用户完全不知道这个按钮有开关状态。
过去,开发者需要手动写 accessibility hint 来解决这个问题:“双击以切换滤镜状态”。但这有几个问题:每个开发者写的文案不一致,用户在不同 App 里听到不同描述;更糟的是,系统不知道这是 toggle,VoiceOver 不会自动报告当前状态。
Apple 的方案:六项无障碍增强
WWDC 2023 带来了六项关键改进,覆盖了按钮状态声明、公告控制、手势交互、视觉无障碍和属性自动同步。
详细内容
1. Toggle Trait:让按钮状态自动播报
isToggle trait 解决了自定义开关按钮的无障碍描述问题。加了这行代码,VoiceOver 会自动说”切换按钮,已开启”或”已关闭”,不需要手动写 hint。
SwiftUI(01:54):
import SwiftUI
struct FilterButton: View {
@State var filter: Bool = false
var body: some View {
Button(action: { filter.toggle() }) {
Text("Filter")
}
.background(filter ? darkGreen : lightGreen)
.accessibilityAddTraits(.isToggle)
}
}
关键点:
.accessibilityAddTraits(.isToggle)告诉系统这个按钮是 toggle 类型- VoiceOver 会自动读取当前 Bool 状态,不需要手动设置 accessibilityValue
- 系统会给出”双击切换设置”的提示,文案在所有 App 中保持一致
UIKit(02:31):
import UIKit
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let filterButton = UIButton(type: .custom)
setupButtonView()
filterButton.accessibilityTraits = [.toggleButton]
view.addSubview(filterButton)
}
}
关键点:
- UIKit 中使用
.toggleButton作为 accessibilityTraits - 与 SwiftUI 的
.isToggle效果完全一致 - 不再需要设置 accessibilityHint 字符串
2. AccessibilityNotification:跨平台统一通知 API
以前在 SwiftUI 中发送无障碍公告需要调用 UIKit 的 UIAccessibility.post(notification:argument:)。SwiftUI 一直没有自己的通知 API。WWDC 2023 引入了 AccessibilityNotification,一个 Swift 原生的跨平台方案。
发送公告(03:43):
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationView {
PhotoFilterView
.toolbar {
Button(action: {
AccessibilityNotification.Announcement("Loading Photos View")
.post()
}) {
Text("Photos")
}
}
}
}
}
关键点:
AccessibilityNotification.Announcement("消息内容")创建公告对象.post()发出公告,VoiceOver 会朗读括号中的文字- 这个 API 在 SwiftUI、UIKit、AppKit 中都可以使用
- 支持四种通知类型:Announcement(公告)、LayoutChanged(布局变化)、ScreenChanged(屏幕切换)、PageScrolled(页面滚动)
公告优先级(05:13):
这是本次更新中最重要的改进。过去多个公告同时触发时,它们会互相打断或堆叠,用户听到混乱的语音。现在公告支持三个优先级:
import SwiftUI
struct ZoomingImageView: View {
var defaultPriorityAnnouncement = AttributedString("Opening Camera")
var lowPriorityAnnouncement: AttributedString {
var lowPriorityString = AttributedString("Camera Loading")
lowPriorityString.accessibilitySpeechAnnouncementPriority = .low
return lowPriorityString
}
var highPriorityAnnouncement: AttributedString {
var highPriorityString = AttributedString("Camera Active")
highPriorityString.accessibilitySpeechAnnouncementPriority = .high
return highPriorityString
}
// ...
}
关键点:
- 优先级通过
AttributedString的accessibilitySpeechAnnouncementPriority属性设置 - 三级:
.high(立即打断当前语音,自身不可被打断)、.default(正常排队)、.low(排在所有 default 之后) - 需要同时发出多个公告时,
.high保证关键信息优先送达
实际使用场景(05:46):
import SwiftUI
struct CameraButton: View {
// ...
var body: some View {
Button(action: {
// Open Camera Code
AccessibilityNotification.Announcement(defaultPriorityAnnouncement).post()
// Camera Loading Code
AccessibilityNotification.Announcement(lowPriorityAnnouncement).post()
// Camera Loaded Code
AccessibilityNotification.Announcement(highPriorityAnnouncement).post()
}) {
Image("Camera")
}
}
}
}
关键点:
- 打开相机时连续发出三条公告,优先级确保”Camera Active”先被听到
.low优先级的”Camera Loading”不会打断其他重要信息.default优先级的公告按正常顺序排队
UIKit 对应写法(06:15):
class ViewController: UIViewController {
let defaultAnnouncement = NSAttributedString(string: "Opening Camera", attributes:
[NSAttributedString.Key.UIAccessibilitySpeechAttributeAnnouncementPriority:
UIAccessibilityPriority.default]
)
let lowPriorityAnnouncement = NSAttributedString(string: "Camera Loading", attributes:
[NSAttributedString.Key.UIAccessibilitySpeechAttributeAnnouncementPriority:
UIAccessibilityPriority.low]
)
let highPriorityAnnouncement = NSAttributedString(string: "Camera Active", attributes:
[NSAttributedString.Key.UIAccessibilitySpeechAttributeAnnouncementPriority:
UIAccessibilityPriority.high]
)
// ...
}
关键点:
- UIKit 中使用
NSAttributedString的UIAccessibilitySpeechAttributeAnnouncementPriority属性 - 对应的常量是
UIAccessibilityPriority.high、.default、.low
3. Zoom Action:让 VoiceOver 用户也能缩放图片
图片查看器里的双指捏合缩放对 VoiceOver 用户是不可用的——因为 VoiceOver 手势拦截了触摸事件。新的 accessibilityZoomAction 允许 VoiceOver 用户通过无障碍手势完成缩放。
SwiftUI(06:56):
struct ZoomingImageView: View {
@State private var zoomValue = 1.0
@State var imageName: String?
var body: some View {
Image(imageName ?? "")
.scaleEffect(zoomValue)
.accessibilityZoomAction { action in
let zoomQuantity = "\(Int(zoomValue)) x zoom"
switch action.direction {
case .zoomIn:
zoomValue += 1.0
AccessibilityNotification.Announcement(zoomQuantity).post()
case .zoomOut:
zoomValue -= 1.0
AccessibilityNotification.Announcement(zoomQuantity).post()
}
}
}
}
关键点:
.accessibilityZoomAction接受一个闭包,闭包参数提供action.directionaction.direction有两个值:.zoomIn和.zoomOut- 缩放后立即用
AccessibilityNotification.Announcement报告当前倍数,用户知道缩放到什么程度了
import UIKit
class ZoomingImageView: UIScrollView {
override func accessibilityZoomIn(at point: CGPoint) -> Bool {
zoomScale += 1.0
let zoomQuantity = "\(Int(zoomScale)) x zoom"
UIAccessibility.post(notification: .announcement, argument: zoomQuantity)
return true
}
override func accessibilityZoomOut(at point: CGPoint) -> Bool {
zoomScale -= 1.0
let zoomQuantity = "\(Int(zoomScale)) x zoom"
UIAccessibility.post(notification: .announcement, argument: zoomQuantity)
return true
}
}
关键点:
- UIKit 中通过重写
UIScrollView的accessibilityZoomIn(at:)和accessibilityZoomOut(at:)方法实现 - 需要先将视图的
accessibilityTraits设置为[.image, .supportsZoom] - 返回
true表示缩放操作已处理
4. Direct Touch:让 VoiceOver 手势穿透到 App
有些场景下 VoiceOver 的朗读反而干扰操作。比如一个钢琴键盘 App,用户需要快速连续按键,每次按键 VoiceOver 都读出来反而成了噪音。
Direct Touch 允许指定某个视图区域,让用户的手势直接传递给 App,跳过 VoiceOver 的拦截。
SwiftUI(10:10):
import SwiftUI
struct KeyboardKeyView: View {
var soundFile: String
var body: some View {
Rectangle()
.fill(.white)
.frame(width: 35, height: 80)
.onTapGesture(count: 1) {
playSound(sound: soundFile, type: "mp3")
}
.accessibilityDirectTouch(options: .silentOnTouch)
}
}
关键点:
.accessibilityDirectTouch(options: .silentOnTouch)让该区域支持直接触摸,且触摸时不朗读- VoiceOver 在探索(explore)模式下仍会朗读该元素,帮助用户定位
- 一旦用户激活直接触摸,所有后续手势直接传给 App,直到用户退出该区域
UIKit(10:46):
import UIKit
class ViewController: UIViewController {
let waveformButton = UIButton(type: .custom)
override func viewDidLoad() {
super.viewDidLoad()
waveformButton.accessibilityTraits = .allowsDirectInteraction
waveformButton.accessibilityDirectTouchOptions = .silentOnTouch
waveformButton.addTarget(self, action: #selector(playTone), for: .touchUpInside)
view.addSubview(waveformButton)
}
}
关键点:
- UIKit 中设置
accessibilityTraits = .allowsDirectInteraction允许直接交互 accessibilityDirectTouchOptions = .silentOnTouch可选,用于关闭触摸时的语音反馈
5. 视觉无障碍增强
Content Shape 自定义(12:21):
SwiftUI 中 VoiceOver 聚焦时的边框默认是矩形,但有时圆形元素用矩形边框会让用户困惑。新的 .contentShape API 支持为无障碍聚焦设置自定义形状:
import SwiftUI
struct ImageView: View {
var body: some View {
Image("circle-red")
.resizable()
.frame(width: 200, height: 200)
.accessibilityLabel("Red")
.contentShape(.accessibility, Circle())
}
}
关键点:
.contentShape(.accessibility, Circle())让 VoiceOver 聚焦高亮显示为圆形- 第一个参数
.accessibility指定这个形状只用于无障碍,不影响点击区域 - 支持任何 SwiftUI Shape 类型
6. UIKit 属性自动同步:告别手动更新
过去在 UIKit 中,当 UI 状态变化时(比如滤镜开关),开发者需要记得同步更新 accessibilityValue。漏掉一次,VoiceOver 用户就听到错误的状态。
WWDC 2023 引入了 block-based 属性设置器,让无障碍属性值自动从 UI 状态派生:
import UIKit
class ViewController: UIViewController {
var isFiltered = false
override func viewDidLoad() {
super.viewDidLoad()
// Set up views
zoomView.accessibilityValueBlock = { [weak self] in
guard let self else { return nil }
return isFiltered ? "Filtered" : "Not Filtered"
}
}
}
关键点:
accessibilityValueBlock接受一个闭包,每次 VoiceOver 需要读取值时调用- 闭包通过
[weak self]避免循环引用 - 只要
isFiltered变了,下次 VoiceOver 读取时自动得到最新值 - 不需要在每次状态变更处手动设置
accessibilityValue
核心启发
1. 改造所有自定义开关按钮
检查 App 中所有自定义的 toggle 按钮——任何实现了开关行为的 UIButton 或 SwiftUI Button。给它们加上 .isToggle trait(SwiftUI)或 .toggleButton(UIKit)。这会立刻提升 VoiceOver 用户的体验,工作量只需加一行代码。
2. 为关键操作添加带优先级的公告
找出 App 中那些”用户必须立刻知道”的状态变化——比如支付成功、文件上传完成、导航到达目的地。用 .high 优先级的 AccessibilityNotification.Announcement 替换现有的无声状态更新。对于进度类信息(如”正在加载更多”),用 .low 优先级避免打断更重要信息。
3. 为图片查看器添加无障碍缩放
如果 App 中有任何图片浏览或地图查看功能,添加 accessibilityZoomAction。VoiceOver 用户通过 rotor 手势就能放大缩小,不需要物理捏合。记得缩放后播报当前倍数。
4. 识别 Direct Touch 场景
找出 App 中需要快速连续操作的元素——键盘、画板、游戏控制器、音乐打击垫。为这些区域启用 Direct Touch,让 VoiceOver 用户的交互延迟降到最低。同时确保该区域有清晰的 accessibilityLabel,让用户在探索模式下知道这里是什么。
5. 迁移 UIKit 无障碍属性到 Block 模式
搜索项目中所有手动设置 accessibilityValue、accessibilityHint 的代码。将它们迁移到 accessibilityValueBlock 等 block-based 属性。这会消除状态同步 bug——最常见的无障碍问题之一。
关联 Session
- Explore media formats for the web — WWDC23 Keynote 中提到的可访问性改进概述
- Design considerations for vision and motion — 视觉与动效设计如何影响无障碍体验
- Extend Speech Synthesis with personal and custom voices — 语音合成在无障碍场景中的应用
- Meet Assistive Access — 为认知障碍用户设计的辅助访问模式
- Explore enhancements to RoomPlan — RoomPlan 的无障碍增强
评论
GitHub Issues · utterances