WWDC Quick Look 💓 By SwiftGGTeam
SwiftUI Accessibility: Beyond the basics

SwiftUI Accessibility: Beyond the basics

观看原视频

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 可以整理导航顺序。accessibilityRotorAccessibilityFocusState 则让复杂界面拥有更快的 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 上,用于进入好友详情或执行主操作。
  • SymbolButtonStyleaccessibilityRepresentation 把只有图标的按钮恢复成有文字标签的按钮。

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.postUIAccessibility.post 发公告。
  • NotificationBanner 里用计时器自动隐藏通知,但在 isNotificationFocused 为 true 时保留横幅。

这个模式适合支付失败、连接断开、权限错误等需要立即处理的消息。普通提示可以读出来,关键提示可以拿到焦点。

核心启发

  • 做什么:给自定义价格滑块、评分控件和进度控件补上辅助功能表示。 为什么值得做accessibilityRepresentation 可以让自绘控件复用标准控件的 VoiceOver 行为。 怎么开始:先用 Accessibility Preview 检查控件是否可调节,再用 SliderStepperToggle 作为 representation。

  • 做什么:为图表增加逐项读数。 为什么值得做Canvas 画出的柱状图和折线图没有天然语义,accessibilityChildren 可以把数据点交给 VoiceOver。 怎么开始:把图表数据模型做成 Identifiable,在 accessibilityChildren 里用 ForEach 生成 label 和 value。

  • 做什么:优化横向卡片列表的 VoiceOver 滑动次数。 为什么值得做.contain.combineaccessibilitySortPriority 可以控制 cell 的导航层级和顺序。 怎么开始:打开 Accessibility Preview,统计用户经过一张卡片要滑几次,再决定保留子元素还是合并成单个元素。

  • 做什么:给邮件、告警、订单列表加入自定义 rotor。 为什么值得做accessibilityRotor 可以让 VoiceOver 用户直接跳到未读、警告、失败订单等关键项。 怎么开始:先定义过滤条件,再用 AccessibilityRotorEntry 把符合条件的模型加入 rotor。

  • 做什么:让高优先级横幅自动获得辅助功能焦点。 为什么值得做AccessibilityFocusState 可以区分普通公告和必须立即处理的提示。 怎么开始:给通知模型加 priority 字段,高优先级绑定 .accessibilityFocused,低优先级调用 UIAccessibility.postNSAccessibility.post

关联 Session

评论

GitHub Issues · utterances