WWDC Quick Look 💓 By SwiftGGTeam
Tailor the VoiceOver experience in your data-rich apps

Tailor the VoiceOver experience in your data-rich apps

观看原视频

Highlight

Apple 提供 Accessibility Custom Content API,让数据密集型界面把核心信息交给 VoiceOver 直接朗读,把补充信息放进 More Content rotor 供用户按需访问。

核心内容

一个列表单元格看起来很普通:狗的照片、名字、品种、性格描述、受欢迎程度、年龄、体重、身高。

VoiceOver 默认会按界面顺序朗读这些内容。用户滑到第一只狗时,会听到名字、品种、长句描述、排名、年龄、体重、身高。再滑到下一只狗,又重复一次。

问题出在信息密度。朗读太多,用户要在一长串语音里找重点。朗读太少,用户又拿不到完整信息。不同用户关心的信息也不同:有人先看年龄,有人先看体重,有人只想快速扫过列表。

Apple 在 Accessibility framework 中提供 Accessibility Custom Content API。开发者可以把元素拆成两层:辅助功能标签只保留核心内容,例如狗名和品种;详细字段放入自定义内容,交给 More Content rotor。

结果很直接。VoiceOver 聚焦到单元格时先说“Bailey, beagle. More content available.” 用户想听细节时,打开 More Content rotor,再逐项浏览年龄、受欢迎程度、体重、身高和描述。

这个能力解决的是数据密集界面的节奏问题。默认朗读保持短,详细内容仍然可访问,用户自己决定什么时候听。

详细内容

用 More Content rotor 承载补充信息

01:50

Accessibility Custom Content API 由 Accessibility framework 提供,演讲说明它在所有 Apple 平台可用。它的目标是让辅助技术按“适量”呈现信息。

在 iOS 上,用户把 VoiceOver rotor 转到 More Content,然后上下滑动浏览补充内容。在 macOS 上,VoiceOver 光标停在元素上后,可以用 VO + Command + / 打开 More Content 菜单。

关键 API 是 AXCustomContentProviderAXCustomContent

import UIKit
import Accessibility

class DogTableViewCell: UITableViewCell, AXCustomContentProvider {

    var coverImage: UIImageView!
    var name: UILabel!
    var type: UILabel!
    var desc: UILabel!
    var popularity: UILabel!
    var age: UILabel!
    var weight: UILabel!
    var height: UILabel!

    override var accessibilityLabel: String? {
        get {
            guard let nameLabel = name.text else { return nil }
            guard let typeLabel = type.text else { return nil }
            return nameLabel + ", " + typeLabel
        }
        set { }
    }

    var accessibilityCustomContent: [AXCustomContent]! {
        get {
            let notes = AXCustomContent(label: "Description", value: desc.text!)
            let popularity = AXCustomContent(label: "Popularity", value: popularity.text!)
            let age = AXCustomContent(label: "Age", value: age.text!)
            let weight = AXCustomContent(label: "Weight", value: weight.text!)
            let height = AXCustomContent(label: "Height" , value: height.text!)
            return [age, popularity, weight, height, notes]
        }
        set { }
    }
}

关键点:

  • import Accessibility 引入 Accessibility framework,AXCustomContentAXCustomContentProvider 来自这里。
  • DogTableViewCell 实现 AXCustomContentProvider,表示这个 cell 可以提供自定义辅助内容。
  • accessibilityLabel 只返回 nametype,让 VoiceOver 聚焦时先读最重要的信息。
  • AXCustomContent(label:value:) 把一个字段做成键值对,例如 "Age"age.text!
  • accessibilityCustomContent 返回数组,数组顺序决定 VoiceOver 在 More Content rotor 中呈现字段的顺序。

用 importance 把关键信息提前朗读

06:49

有些补充信息仍然应该默认朗读。演讲里的例子是狗的年龄。对于想领养的人来说,年龄可能是决策字段,藏在 rotor 里会增加操作成本。

AXCustomContentimportance 属性。把它设为 .high 后,VoiceOver 聚焦到元素时会直接朗读这个字段,同时它仍然会出现在 More Content rotor 中。

import UIKit
import Accessibility

class DogTableViewCell: UITableViewCell, AXCustomContentProvider {

    var coverImage: UIImageView!
    var name: UILabel!
    var type: UILabel!
    var desc: UILabel!
    var popularity: UILabel!
    var age: UILabel!
    var weight: UILabel!
    var height: UILabel!

    override var accessibilityLabel: String? {
        get {
            guard let nameLabel = name.text else { return nil }
            guard let typeLabel = type.text else { return nil }
            return nameLabel + ", " + typeLabel
        }
        set { }
    }

    var accessibilityCustomContent: [AXCustomContent]! {
        get {
            let notes = AXCustomContent(label: "Description", value: desc.text!)
            let popularity = AXCustomContent(label: "Popularity", value: popularity.text!)
            let age = AXCustomContent(label: "Age", value: age.text!)
            age.importance = .high
            let weight = AXCustomContent(label: "Weight", value: weight.text!)
            let height = AXCustomContent(label: "Height" , value: height.text!)
            return [popularity, age, weight, height, notes]
        }
        set { }
    }
}

关键点:

  • let age = AXCustomContent(label: "Age", value: age.text!) 创建年龄字段。
  • age.importance = .high 把年龄标记为高优先级。
  • VoiceOver 聚焦 cell 时会读出狗名、品种和年龄。
  • return [popularity, age, weight, height, notes] 保留年龄在 rotor 中的访问入口。

在 SwiftUI 中添加自定义内容

07:47

WWDC21 也把同一能力带到 SwiftUI。最小做法是在视图上使用 accessibilityCustomContent modifier,传入 label、value 和可选的 importance

struct SampleView: View {
    var body: some View {
        VStack {
            Text(name)
            Text(description)
        }
        .accessibilityElement(children: .combine)
        .accessibilityCustomContent("Description", description, importance: .high)
    }
}

关键点:

  • VStack 里有 Text(name)Text(description) 两段文本。
  • .accessibilityElement(children: .combine) 把子元素合并成一个辅助功能元素。
  • .accessibilityCustomContent("Description", description, importance: .high) 把描述字段加入自定义内容。
  • importance: .high 让这个描述在元素聚焦时直接被 VoiceOver 呈现。

整理 SwiftUI cell 的默认朗读内容

08:10

SwiftUI 会自动从 Text 推断辅助功能内容。数据密集型 cell 里,这会让描述和各个数值字段自动进入朗读队列。

演讲的处理方式是:先对补充字段使用 accessibilityHidden(true),避免它们进入默认 label;再把同样的数据通过 accessibilityCustomContent 加回来。

import SwiftUI
import Accessibility

struct DogCell: View {
    var dog: Dog
    var body: some View {
        VStack {
            HStack {
                dog.image
                    .resizable()
                VStack(alignment: .leading) {
                    Text(dog.name)
                        .font(.title)
                    Spacer()
                    Text(dog.type)
                        .font(.body)
                    Spacer()
                    Text(dog.description)
                        .fixedSize(horizontal: false, vertical: true)
                        .font(.subheadline)
                        .foregroundColor(Color(uiColor: UIColor.brown))
                        .accessibilityHidden(true)
                }
                Spacer()
            }
            .padding(.horizontal)
            HStack(alignment: .top) {
                VStack(alignment: .leading) {
                    HStack {
                        Text(dog.popularity)
                        Spacer()
                        Text(dog.age)
                        Spacer()
                        Text(dog.weight)
                        Spacer()
                        Text(dog.height)
                    }
                    .foregroundColor(Color(uiColor: UIColor.darkGray))
                    .accessibilityHidden(true)
                }
                Spacer()
            }
            .padding(.horizontal)
            Divider()
        }
        .accessibilityElement(children: .combine)
        .accessibilityCustomContent("Age", dog.age, importance: .high)
        .accessibilityCustomContent("Popularity", dog.popularity)
        .accessibilityCustomContent("Weight", dog.weight)
        .accessibilityCustomContent("Height", dog.height)
        .accessibilityCustomContent("Description", dog.description)
    }
}

关键点:

  • Text(dog.description) 仍然显示在界面上,但 .accessibilityHidden(true) 阻止它进入默认朗读。
  • 包含 popularityageweightheightHStack 也被隐藏,避免 VoiceOver 一次读完所有数值。
  • .accessibilityElement(children: .combine) 把整个 VStack 作为一个可聚焦元素。
  • .accessibilityCustomContent("Age", dog.age, importance: .high) 让年龄默认朗读,并保留在 More Content rotor 中。
  • 后续四个 .accessibilityCustomContent 把受欢迎程度、体重、身高和描述放入 rotor。

用 AccessibilityCustomContentKey 复用键

08:57

如果同一个自定义内容值会在 SwiftUI 层级中多处更新或移除,演讲建议创建 AccessibilityCustomContentKey 扩展。这样可以复用键,避免在不同位置重复写本地化字符串。

import SwiftUI
import Accessibility

extension AccessibilityCustomContentKey {
    static var age: AccessibilityCustomContentKey {
        AccessibilityCustomContentKey("Age")
    }
}

struct DogCell: View {
    var dog: Dog
    var body: some View {
        VStack {
            HStack {
                dog.image
                    .resizable()
                VStack(alignment: .leading) {
                    Text(dog.name)
                        .font(.title)
                    Spacer()
                    Text(dog.type)
                        .font(.body)
                    Spacer()
                    Text(dog.description)
                        .fixedSize(horizontal: false, vertical: true)
                        .font(.subheadline)
                        .foregroundColor(Color(uiColor: UIColor.brown))
                        .accessibilityHidden(true)
                }
                Spacer()
            }
            .padding(.horizontal)
            HStack(alignment: .top) {
                VStack(alignment: .leading) {
                    HStack {
                        Text(dog.popularity)
                        Spacer()
                        Text(dog.age)
                        Spacer()
                        Text(dog.weight)
                        Spacer()
                        Text(dog.height)
                    }
                    .foregroundColor(Color(uiColor: UIColor.darkGray))
                    .accessibilityHidden(true)
                }
                Spacer()
            }
            .padding(.horizontal)
            Divider()
        }
        .accessibilityElement(children: .combine)
        .accessibilityCustomContent(.age, dog.age, importance: .high)
        .accessibilityCustomContent("Popularity", dog.popularity)
        .accessibilityCustomContent("Weight", dog.weight)
        .accessibilityCustomContent("Height", dog.height)
        .accessibilityCustomContent("Description", dog.description)
    }
}

关键点:

  • extension AccessibilityCustomContentKey 给自定义内容键增加静态属性。
  • AccessibilityCustomContentKey("Age") 创建可复用的年龄键。
  • .accessibilityCustomContent(.age, dog.age, importance: .high) 使用键对象替代字符串。
  • 复用键后,层级中其他位置可以更新或移除同一项内容。

核心启发

  • 做什么:给商品列表做“核心信息 + 详细参数”的 VoiceOver 结构。 为什么值得做:商品卡片通常包含价格、规格、库存、促销和描述,默认朗读容易变长。Accessibility Custom Content 可以把规格和描述放入 More Content rotor。 怎么开始:让 cell 实现 AXCustomContentProvideraccessibilityLabel 只保留商品名和价格,规格字段用 AXCustomContent(label:value:) 返回。

  • 做什么:给宠物领养、房源、二手车这类筛选列表设置高优先级字段。 为什么值得做:年龄、面积、里程等字段会直接影响选择,用户不应每次都打开 rotor 才能听到。 怎么开始:为关键字段创建 AXCustomContent,设置 importance = .high,再检查 VoiceOver 聚焦时的朗读顺序。

  • 做什么:把 SwiftUI 复杂卡片的视觉文本和辅助功能文本分开维护。 为什么值得做:卡片可以继续显示丰富排版,VoiceOver 默认只读核心内容,补充字段通过 accessibilityCustomContent 提供。 怎么开始:对描述和参数区域加 .accessibilityHidden(true),在外层容器上使用 .accessibilityElement(children: .combine) 和多次 .accessibilityCustomContent

  • 做什么:为会重复更新的字段定义 AccessibilityCustomContentKey为什么值得做:同一字段可能在父视图和子视图中被设置、更新或移除,复用键可以减少字符串重复。 怎么开始:扩展 AccessibilityCustomContentKey,创建 static var,再把 .accessibilityCustomContent("Age", ...) 改成 .accessibilityCustomContent(.age, ...)

关联 Session

评论

GitHub Issues · utterances