Highlight
Apple 在 SwiftUI 中加入 Accessibility Preview、
accessibilityRepresentation、自定义 rotor 和辅助功能焦点 API,让开发者可以检查、修正并增强 VoiceOver 等辅助技术的导航体验。
核心内容
Nathan Tannar 在演讲里做了一个理财应用原型,名字叫 Wallet Pal。界面看起来已经打磨过,早期测试用户也喜欢它。但是 VoiceOver 用户反馈:应用难导航,有些部分无法访问。
问题发生在颜色、动效、排版之外。VoiceOver 读取一套辅助功能界面:元素顺序、标签、traits、值、可执行动作。如果自定义控件只画出了形状,却没有告诉系统它的语义,辅助技术就无法正确操作它。
SwiftUI 原本会自动生成很多辅助功能信息。Text 会变成带标签的静态文本,SF Symbols 会有默认标签,标准 Slider 会有可调节值。但应用一旦使用 Canvas、自定义手势、叠加按钮和复杂列表,默认推断就不够了。
Apple 在 WWDC21 给 SwiftUI 补上了几类能力。Xcode 13 的 Accessibility Preview 可以直接在预览里检查元素。accessibilityRepresentation 可以让自定义控件借用标准控件的语义。accessibilityChildren 可以给绘制出来的图形补充结构。accessibilityElement(children:) 和 accessibilitySortPriority 可以整理导航顺序。accessibilityRotor 和 AccessibilityFocusState 则让复杂界面拥有更快的 VoiceOver 路径。
这些 API 的共同目标很明确:视觉界面继续按产品需求定制,辅助功能界面单独补充语义和导航规则。开发者不用放弃自定义 UI,也不用让 VoiceOver 用户在无意义的元素里来回滑动。
详细内容
Accessibility Preview:先看见辅助功能树
(02:00)
Xcode 13 在 SwiftUI Previews 里加入 Accessibility Preview。它会显示当前 view 生成的辅助功能元素,包括 label、traits 和排序。演讲用一个简单的 VStack 展示了这一点。
struct ContentView: View {
var body: some View {
VStack {
Text("WWDC 2021")
.accessibilityAddTraits(.isHeader)
Text("SwiftUI Accessibility")
Text("Beyond the Basics")
Image(systemName: "checkmark.seal.fill")
}
}
}
关键点:
VStack里的每个Text会自动成为辅助功能元素。Text("WWDC 2021")原本是静态文本,.accessibilityAddTraits(.isHeader)给它加上标题语义。Image(systemName: "checkmark.seal.fill")使用 SF Symbol,系统会自动给它生成默认标签。演讲里这个符号的默认标签是Verified。- Accessibility Preview 会实时显示这些变化,不需要运行完整应用。
这个工具解决的是第一步问题:你先知道 VoiceOver 实际看到了什么,再决定要补哪一段语义。
accessibilityRepresentation:让自定义控件借用标准控件语义
(05:50)
Wallet Pal 有一个预算滑块。它用自定义形状和拖拽手势画出来,视觉上像滑块,但 VoiceOver 并不知道它可以调节。SwiftUI 提供 accessibilityRepresentation,让这个自定义滑块在辅助功能层面表现成标准 Slider。
struct BudgetSlider: View {
@Binding var value: Double
var label: String
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(label)
Text(value.toDollars()).bold()
}
SliderShape(value: value)
.gesture(DragGesture().onChanged(handle))
.accessibilityRepresentation {
Slider(value: $value, in: 0...1) {
Text(label)
}
.accessibilityValue(value.toDollars())
}
}
}
}
struct SliderShape: View {
var value: Double
private struct BackgroundTrack: View {
var cornerRadius: CGFloat
var body: some View {
RoundedRectangle(
cornerRadius: cornerRadius,
style: .continuous
)
.foregroundColor(Color(white: 0.2))
}
}
private struct OverlayTrack: View {
var cornerRadius: CGFloat
var body: some View {
RoundedRectangle(
cornerRadius: cornerRadius,
style: .continuous
)
.foregroundColor(Color(white: 0.95))
}
}
private struct Knob: View {
var cornerRadius: CGFloat
var body: some View {
RoundedRectangle(
cornerRadius: cornerRadius,
style: .continuous
)
.strokeBorder(Color(white: 0.7), lineWidth: 1)
.shadow(radius: 3)
}
}
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
BackgroundTrack(cornerRadius: geometry.size.height / 2)
OverlayTrack(cornerRadius: geometry.size.height / 2)
.frame(
width: max(geometry.size.height, geometry.size.width * CGFloat(value) + geometry.size.height / 2),
height: geometry.size.height)
Knob(cornerRadius: geometry.size.height / 2)
.frame(
width: geometry.size.height,
height: geometry.size.height)
.offset(x: max(0, geometry.size.width * CGFloat(value) - geometry.size.height / 2), y: 0)
}
}
}
}
extension Double {
func toDollars() -> String {
return "$\(Int(self))"
}
}
关键点:
BudgetSlider保留原来的视觉结构:上方显示标签和值,下方显示自定义滑块形状。SliderShape(value: value)负责绘制轨道、进度和 knob。.gesture(DragGesture().onChanged(handle))继续处理视觉控件的拖拽交互。.accessibilityRepresentation不改变视觉 UI,它只替换辅助功能暴露出去的语义。- 闭包里的
Slider(value: $value, in: 0...1)让辅助技术把它当成标准滑块处理。 .accessibilityValue(value.toDollars())把内部数值转换成用户能听懂的美元金额。
这个 API 适合自定义控件。你已经有一个可视化控件,也知道它对应哪个标准控件,就用标准控件作为辅助功能表示。
accessibilityChildren:给 Canvas 图形补结构
(09:40)
Wallet Pal 还有预算历史图表。图表用 Canvas 绘制柱状图。对 VoiceOver 来说,一块绘制区域没有天然结构。SwiftUI 提供 accessibilityChildren,让开发者为图形补充可访问的子元素。
struct Budget: Identifiable {
var month: String
var amount: Double
var id: String { month }
}
struct BudgetHistoryGraph: View {
var budgets: [Budget]
var body: some View {
GeometryReader { proxy in
VStack {
Canvas { ctx, size in
let inset: CGFloat = 25
let insetSize = CGSize(width: size.width, height: size.height - inset * 2)
let width = insetSize.width / CGFloat(budgets.count)
let max = budgets.map(\.amount).max() ?? 0
for n in budgets.indices {
let x = width * CGFloat(n)
let height = (CGFloat(budgets[n].amount) / CGFloat(max)) * insetSize.height
let y = insetSize.height - height
let p = Path(
roundedRect: CGRect(
x: x + 2.5,
y: y + inset,
width: width - 5,
height: height),
cornerRadius: 4)
ctx.fill(p, with: .color(Color.green))
ctx.draw(Text(budgets[n].amount.toDollars()), at: CGPoint(x: x + width / 2, y: y + inset / 2))
ctx.draw(Text(budgets[n].month), at: CGPoint(x: x + width / 2, y: y + height + 1.5*inset))
}
}
.accessibilityLabel("Budget History Graph")
.accessibilityChildren {
HStack {
ForEach(budgets) { budget in
Rectangle()
.accessibilityLabel(budget.month)
.accessibilityValue(budget.amount.toDollars())
}
}
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.foregroundColor(Color(white: 0.9)))
.padding(.horizontal)
}
}
关键点:
Canvas负责把每个月的预算金额画成柱状图。.accessibilityLabel("Budget History Graph")给整张图一个名称。.accessibilityChildren提供一组辅助功能子元素,不影响屏幕上的绘制结果。ForEach(budgets)为每个月生成一个Rectangle作为语义占位。.accessibilityLabel(budget.month)让 VoiceOver 读出月份。.accessibilityValue(budget.amount.toDollars())让 VoiceOver 读出该月金额。
图表要交给辅助技术的核心信息是每个数据点。accessibilityChildren 把这些数据点交给辅助技术。
accessibilityElement:整理复杂 cell 的导航方式
(15:10)
复杂列表里常见一个问题:一个 cell 里有图片、名字、按钮和点击手势。VoiceOver 逐个进入每个内部元素,用户要滑很多次才能越过一行。SwiftUI 用 accessibilityElement(children:) 控制父子元素的呈现方式。
struct User: Identifiable {
var id: Int
var name: String
var photo: String
}
struct FriendCellView: View {
var user: User
var body: some View {
ZStack(alignment: .topLeading) {
VStack(alignment: .center) {
Image(user.photo)
Text(user.name)
}
Button("Send Challenge", action: { /* ... */ })
.buttonStyle(
SymbolButtonStyle(
systemName: "gamecontroller.fill"))
}
}
}
struct FriendsView: View {
var users: [User]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(users) { user in
FriendCellView(user: user)
.accessibilityElement(children: .contain)
.onTapGesture { /* ... */ }
}
AddFriendButton()
Spacer()
}
}
}
}
struct AddFriendButton: View {
var body: some View {
Button(action: { /* ... */ }) {
Circle()
.foregroundColor(Color(white: 0.9))
.frame(width: 50, height: 50)
.overlay(
Image(systemName: "plus")
.resizable()
.foregroundColor(Color(white: 0.5))
.padding(15)
)
}
.buttonStyle(PlainButtonStyle())
}
}
struct SymbolButtonStyle: ButtonStyle {
let systemName: String
func makeBody(configuration: Configuration) -> some View {
Image(systemName: systemName)
.accessibilityRepresentation { configuration.label }
}
}
关键点:
FriendCellView里同时有头像、名字和挑战按钮。ForEach(users)在横向滚动区域里生成一组好友 cell。.accessibilityElement(children: .contain)把 cell 变成一个容器,内部元素仍然保留。.onTapGesture仍然绑定在 cell 上,用于进入好友详情或执行主操作。SymbolButtonStyle用accessibilityRepresentation把只有图标的按钮恢复成有文字标签的按钮。
(16:20)
如果内部元素顺序不符合用户操作习惯,可以用 accessibilitySortPriority 调整顺序。演讲里把挑战按钮的优先级设为 -1,让它排在名字之后。
struct FriendCellView: View {
var user: User
var body: some View {
ZStack(alignment: .topLeading) {
VStack(alignment: .center) {
Image(user.photo)
Text(user.name)
}
Button("Send Challenge", action: { /* ... */ })
.buttonStyle(
SymbolButtonStyle(
systemName: "gamecontroller.fill"))
.accessibilitySortPriority(-1)
}
}
}
关键点:
Button("Send Challenge")是一个可执行动作。.buttonStyle(SymbolButtonStyle(...))把它视觉上显示成图标按钮。.accessibilitySortPriority(-1)调整该按钮在辅助功能导航中的排序。- 这个修饰符适合解决局部排序问题,不需要改变视觉层级。
(16:55)
如果一个 cell 的内部细节没有必要逐项导航,可以把子元素合并成一个元素。
ForEach(users) { user in
FriendCellView(user: user)
.accessibilityElement(children: .combine)
.onTapGesture { /* ... */ }
}
关键点:
.combine会把FriendCellView的子元素合并成一个辅助功能元素。- 用户滑动一次就能经过一个好友,无需进入头像、名字、按钮的每个细节。
.onTapGesture保留 cell 的主操作。- 合并适合内容预览类 cell。需要单独操作的按钮,应该谨慎合并。
accessibilityRotor:给 VoiceOver 加快速通道
(20:30)
VoiceOver rotor 是一种导航工具。用户可以选择一种类别,然后在匹配项之间快速跳转。SwiftUI 的 accessibilityRotor 允许应用为自己的内容定义 rotor。
struct Alert: Identifiable {
var id: Int
var isUnread: Bool
var isFlagged: Bool
var subject: String
var content: String
}
struct AlertsView: View {
var alerts: [Alert]
var body: some View {
VStack {
ForEach(alerts) { alert in
AlertCellView(alert: alert)
.accessibilityElement(children: .combine)
}
}
.accessibilityElement(children: .contain)
.accessibilityRotor("Warnings") {
ForEach(alerts) { alert in
if alert.isWarning {
AccessibilityRotorEntry(alert.title, id: alert.id)
}
}
}
}
}
关键点:
AlertsView显示一组通知或警告。- 每个
AlertCellView用.combine合成一个可导航元素。 - 外层
VStack用.contain保留集合结构。 .accessibilityRotor("Warnings")创建一个名为Warnings的 rotor。AccessibilityRotorEntry(alert.title, id: alert.id)把符合条件的 alert 放入 rotor。
(21:50)
当 rotor 条目和实际可聚焦元素不在同一层级,可以用 namespace 显式关联 entry 和目标。
struct AlertsView: View {
var alerts: [Alert]
@Namespace var namespace
var body: some View {
VStack {
ForEach(alerts) { alert in
VStack {
AlertCellView(alert: alert)
.accessibilityElement(children: .combine)
.accessibilityRotorEntry(id: alert.id, in: namespace)
AlertActionsView(alert: alert)
}
}
}
.accessibilityElement(children: .contain)
.accessibilityRotor("Warnings") {
ForEach(alerts) { alert in
if alert.isWarning {
AccessibilityRotorEntry(alert.title, id: alert.id, in: namespace)
}
}
}
}
}
关键点:
@Namespace var namespace创建一个匹配空间。.accessibilityRotorEntry(id: alert.id, in: namespace)标记实际跳转目标。AccessibilityRotorEntry(..., id: alert.id, in: namespace)在 rotor 中引用同一个目标。AlertActionsView(alert:)可以和 cell 放在一起,但 rotor 仍然跳到 cell 本身。
(22:20)
文本编辑器也可以用 rotor。演讲展示了按邮箱、链接、电话号码在文本范围之间跳转。
struct ContentView: View {
@State var note: Note
var body: some View {
TextEditor($text.content)
.accessibilityRotor("Email Addresses", textRanges: note.addressRanges)
.accessibilityRotor("Links", textRanges: note.linkRanges)
.accessibilityRotor("Phone Numbers", textRanges: note.phoneNumberRanges)
}
}
关键点:
TextEditor负责编辑正文。textRanges:接收文本范围集合。note.addressRanges对应邮箱地址范围。note.linkRanges对应链接范围。note.phoneNumberRanges对应电话号码范围。
这种设计适合信息密集界面。用户不用从头读完整段文本,可以直接跳到自己关心的内容。
AccessibilityFocusState:把焦点交给重要通知
(24:45)
最后一段讲焦点。应用弹出通知时,低优先级内容可以用公告读出。高优先级内容应该直接获得辅助功能焦点。SwiftUI 提供 @AccessibilityFocusState 和 .accessibilityFocused。
struct Notification: Equatable {
enum Priority {
case low, high
}
var content: String
var priority: Priority
}
struct AlertNotificationView<Content: View>: View {
@ViewBuilder var content: Content
@Binding var notification: Notification?
@AccessibilityFocusState var isNotificationFocused: Bool
var body: some View {
ZStack(alignment: .top) {
content
if let notification = $notification {
NotificationBanner(notification: notification)
.accessibilityFocused($isNotificationFocused)
}
}
.onChange(of: notification) { notification in
if notification?.priority == .high {
isNotificationFocused = true
} else {
postAccessibilityNotification()
}
}
}
func postAccessibilityNotification() {
guard let announcement = notification?.content else {
return
}
#if os(macOS)
NSAccessibility.post(
element: NSApp.accessibilityWindow(),
notification: .announcementRequested,
userInfo: [.announcement: announcement])
#else
UIAccessibility.post(notification: .announcement, argument: announcement)
#endif
}
}
struct NotificationBanner: View {
@Binding var notification: Notification?
@State var timer: Timer?
@AccessibilityFocusState var isNotificationFocused: Bool
var body: some View {
if let notification = notification {
Text(notification.content)
.accessibilityFocused($isNotificationFocused)
.onAppear { startTimer() }
.onDisappear { stopTimer() }
} else {
EmptyView()
}
}
func startTimer() {
timer = Timer.scheduledTimer(
withTimeInterval: 3,
repeats: true) { _ in
if !isNotificationFocused {
notification = nil
}
}
}
func stopTimer() {
timer?.invalidate()
}
}
关键点:
@AccessibilityFocusState var isNotificationFocused保存辅助功能焦点状态。.accessibilityFocused($isNotificationFocused)把焦点状态绑定到通知横幅。.onChange(of: notification)在通知变化时执行判断。notification?.priority == .high时,把isNotificationFocused设为true,让高优先级通知获得焦点。- 低优先级通知走
postAccessibilityNotification(),通过NSAccessibility.post或UIAccessibility.post发公告。 NotificationBanner里用计时器自动隐藏通知,但在isNotificationFocused为 true 时保留横幅。
这个模式适合支付失败、连接断开、权限错误等需要立即处理的消息。普通提示可以读出来,关键提示可以拿到焦点。
核心启发
-
做什么:给自定义价格滑块、评分控件和进度控件补上辅助功能表示。 为什么值得做:
accessibilityRepresentation可以让自绘控件复用标准控件的 VoiceOver 行为。 怎么开始:先用 Accessibility Preview 检查控件是否可调节,再用Slider、Stepper或Toggle作为 representation。 -
做什么:为图表增加逐项读数。 为什么值得做:
Canvas画出的柱状图和折线图没有天然语义,accessibilityChildren可以把数据点交给 VoiceOver。 怎么开始:把图表数据模型做成Identifiable,在accessibilityChildren里用ForEach生成 label 和 value。 -
做什么:优化横向卡片列表的 VoiceOver 滑动次数。 为什么值得做:
.contain、.combine和accessibilitySortPriority可以控制 cell 的导航层级和顺序。 怎么开始:打开 Accessibility Preview,统计用户经过一张卡片要滑几次,再决定保留子元素还是合并成单个元素。 -
做什么:给邮件、告警、订单列表加入自定义 rotor。 为什么值得做:
accessibilityRotor可以让 VoiceOver 用户直接跳到未读、警告、失败订单等关键项。 怎么开始:先定义过滤条件,再用AccessibilityRotorEntry把符合条件的模型加入 rotor。 -
做什么:让高优先级横幅自动获得辅助功能焦点。 为什么值得做:
AccessibilityFocusState可以区分普通公告和必须立即处理的提示。 怎么开始:给通知模型加 priority 字段,高优先级绑定.accessibilityFocused,低优先级调用UIAccessibility.post或NSAccessibility.post。
关联 Session
- Tailor the VoiceOver experience in your data-rich apps — 深入讲 VoiceOver 如何呈现复杂数据,适合和自定义 rotor、图表语义一起看。
- Bring accessibility to charts in your app — 专门讲图表可访问性,延伸
accessibilityChildren背后的数据表达问题。 - Support Full Keyboard Access in your iOS app — 关注键盘导航,和 VoiceOver 导航同属于可访问交互质量问题。
- Direct and reflect focus in SwiftUI — 讲 SwiftUI 焦点系统,能补充理解
AccessibilityFocusState的使用场景。 - Create accessible experiences for watchOS — 把可访问性放到 watchOS 场景中讨论,适合做跨平台 SwiftUI 应用时参考。
评论
GitHub Issues · utterances