WWDC Quick Look 💓 By SwiftGGTeam
Create accessible experiences for watchOS

Create accessible experiences for watchOS

观看原视频

Highlight

Apple 在 watchOS 8 扩展了大字号、VoiceOver 和 AssistiveTouch 支持,开发者可以用 SwiftUI 无障碍修饰符让 Apple Watch App 适配视觉和运动辅助功能。

核心内容

Apple Watch 的屏幕很小。很多 watchOS App 会把标题、图标、状态、按钮塞进一个列表单元格。默认字号下看起来正常,用户把文字调大后,任务描述就会被截断,按钮也会挤在一起。

这场演讲用一个植物养护 App 展示问题。主界面显示植物名称、浇水时间、施肥时间、光照状态,还有记录任务的按钮。这个界面信息密度高,正好暴露 watchOS 无障碍的常见问题。

第一个问题是 Dynamic Type(动态字体)。固定字号不会跟随系统文字大小变化。文本只允许一行时,大字号会直接截断。Apple 的建议很具体:用系统 text style(文本样式),允许文本换行,在大字号下切换成垂直布局。

第二个问题是 VoiceOver(屏幕朗读)。一个复杂单元格可能包含多个文字、图片和按钮。VoiceOver 会逐个读出图标名,例如 “Drop, image”。用户要听完很多无意义元素,才能到下一个植物。解决办法是合并元素,并给任务和按钮提供面向场景的标签。

第三个问题是自定义控件。演讲中的浇水频率计数器由减号、数字、加号组成。视觉用户能理解它是一个整体,VoiceOver 用户听到的是三个分散元素。SwiftUI 提供 accessibilityElement()accessibilityAdjustableAction,可以把它变成一个可调节元素。

最后是 watchOS 8 的 AssistiveTouch。用户可以用捏合、握拳或手腕移动操作手表,不需要触摸屏幕。开发者要检查哪些元素能被聚焦,光标框是否足够大,自定义动作是否能出现在动作菜单里。

详细内容

Dynamic Type:用文本样式替代固定字号

04:48)演讲先处理植物名称不会变大的问题。原因是标题使用了固定字号。SwiftUI 在 watchOS 上提供系统文本样式,系统会根据用户的文字大小设置自动缩放。

struct PlantView: View {
    @Binding var plant: Plant

    var body: some View {
        VStack(alignment: .leading) {
            Text(plant.name)
                .font(.title3)
            HStack() {
                PlantImage(plant: plant)
                PlantTaskList(plant: $plant)
            }
            PlantTaskButtons(plant: $plant)
        }
    }
}

关键点:

  • @Binding var plant: Plant 让视图直接读写外部的植物数据。
  • VStack(alignment: .leading) 把标题、内容和按钮纵向排列,并左对齐。
  • Text(plant.name) 显示植物名称。
  • .font(.title3) 使用系统文本样式,文字会跟随系统文字大小变化。
  • PlantImagePlantTaskListPlantTaskButtons 分别负责图片、任务列表和操作按钮。

05:00)标题能缩放后,任务文本还会在大字号下被截断。演讲给出的处理方式是放宽行数限制,给文本换行空间。

struct PlantTaskLabel: View {
    let task: PlantTask
    @Binding var plant: Plant

    var body: some View {
        HStack {
            Image(systemName: task.systemImageName)
                .imageScale(.small)
            Text(plant.stringForTask(task: task))
        }
        .lineLimit(3)
        .font(.caption2)
    }
}

关键点:

  • let task: PlantTask 保存当前任务类型,例如浇水或施肥。
  • Image(systemName: task.systemImageName) 使用任务对应的 SF Symbols 图标。
  • .imageScale(.small) 缩小图标,避免图标抢占文字空间。
  • Text(plant.stringForTask(task: task)) 显示任务状态文字。
  • .lineLimit(3) 最多允许三行,减少大字号下的截断。
  • .font(.caption2) 使用系统文本样式,继续支持 Dynamic Type。

大字号布局:根据 sizeCategory 切换结构

05:48)允许换行后,界面仍然可能拥挤。演讲选择读取 sizeCategory,在字号达到阈值后切换到垂直布局。

struct PlantContainerView: View {
    @Environment(\.sizeCategory) var sizeCategory
    @Binding var plant: Plant

    var body: some View {
        if sizeCategory < .extraExtraLarge {
            PlantViewHorizontal(plant: $plant)
        } else {
            PlantViewVertical(plant: $plant)
        }
    }
}

关键点:

  • @Environment(\.sizeCategory) 读取系统文字大小设置。
  • @Binding var plant: Plant 把同一份植物数据传给不同布局。
  • if sizeCategory < .extraExtraLarge 判断当前字号是否还适合横向布局。
  • PlantViewHorizontal(plant: $plant) 用紧凑布局显示默认字号和较小字号。
  • PlantViewVertical(plant: $plant) 在大字号下给标签和按钮更多垂直空间。

VoiceOver:减少可访问元素数量

08:56)打开 VoiceOver 后,原始界面会读出植物名、图片名、任务文字、图标名和按钮。用户要经过太多元素。演讲指出,NavigationLink 可以自动合并子元素的无障碍信息,让一个植物单元格成为一个可访问元素。

struct PlantCellView: View {
    @EnvironmentObject var plantData: PlantData
    var plant: Plant

    var plantIndex: Int {
        plantData.plants.firstIndex(where: { $0.id == plant.id })!
    }

    var body: some View {
        NavigationLink(destination: PlantEditView(plant: plant).environmentObject(plantData)) {
            PlantContainerView(plant: $plantData.plants[plantIndex])
                .padding()
        }
    }
}

关键点:

  • @EnvironmentObject var plantData: PlantData 从环境读取植物列表。
  • plantIndex 通过 id 找到当前植物在数组中的位置。
  • NavigationLink 让整个单元格可以进入编辑界面。
  • PlantEditView(plant: plant).environmentObject(plantData) 把编辑页和共享数据连接起来。
  • PlantContainerView(plant: $plantData.plants[plantIndex]) 绑定数组里的当前植物。
  • .padding() 扩大单元格内容留白,也让触控区域更舒适。

VoiceOver 标签:把图标名改成任务语义

09:38)SF Symbols 会提供默认读法,例如水滴图标可能被读成 “Drop”。植物养护场景里,用户需要听到 “Watering in five days”。演讲给任务文本添加 accessibilityLabel

struct PlantTaskLabel: View {
    let task: PlantTask
    @Binding var plant: Plant

    var body: some View {
        HStack {
            Image(systemName: task.systemImageName)
                .imageScale(.small)
            Text(plant.stringForTask(task: task))
                .accessibilityLabel(plant.accessibilityStringForTask(task: task))
        }
        .lineLimit(3)
        .font(.caption2)
    }
}

关键点:

  • Text(plant.stringForTask(task: task)) 保留屏幕上显示的短文字。
  • .accessibilityLabel(...) 为 VoiceOver 提供专门的朗读文本。
  • plant.accessibilityStringForTask(task: task) 根据任务类型生成完整语义,例如浇水、施肥、光照。
  • 视觉文本和朗读文本分开后,小屏幕仍可显示短文案,VoiceOver 能读出完整上下文。

10:03)按钮也需要语义标签。只读 “Drop fill button” 没有操作含义。演讲把按钮标签改成 “Log watering” 这类动作描述。

struct PlantButton: View {
    let task: PlantTask
    let action: () -> Void
    @State private var isTapped: Bool = false

    var body: some View {
        Button(action: {
            self.isTapped.toggle()
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                self.isTapped.toggle()
            }
            action()
        }) {
            Image(systemName: task.systemImageFillName)
                .foregroundColor(task.color)
                .scaleEffect(isTapped ? 1.5 : 1)
                .animation(nil, value: 0)
                .rotationEffect(.degrees(isTapped ? 360 : 0))
                .animation(.spring(), value: 0)
                .imageScale(.large)
        }
        .buttonStyle(BorderedButtonStyle())
        .accessibilityLabel("Log \(task.name)")
    }
}

关键点:

  • let action: () -> Void 接收按钮触发后的业务操作。
  • @State private var isTapped 保存临时点击动画状态。
  • Button(action:) 先切换动画状态,再执行传入的 action()
  • DispatchQueue.main.asyncAfter 在 0.5 秒后恢复动画状态。
  • Image(systemName: task.systemImageFillName) 显示任务对应的填充图标。
  • .scaleEffect.rotationEffect 负责点击反馈。
  • .buttonStyle(BorderedButtonStyle()) 使用边框按钮样式。
  • .accessibilityLabel("Log \(task.name)") 告诉 VoiceOver 这是记录某个任务的按钮。

自定义控件:用 adjustable action 表达加减

11:07)浇水频率计数器由三个视觉元素组成。VoiceOver 用户听到 “Remove, button. Eight. Add, button”。演讲把它封装成一个元素,并用上滑和下滑执行增减。

struct PlantTaskFrequency: View {
    let task: PlantTask
    @Binding var plant: Plant
    let increment: () -> Void
    let decrement: () -> Void

    var value: Int {
        switch task {
        case .water:
            return plant.wateringFrequency
        case .fertilize:
            return plant.fertilizingFrequency
        default:
            return 0
        }
    }

    var body: some View {
        Section(header: Text("\(task.name) frequency in days"), content: {
            CustomCounter(value: value, increment: increment, decrement: decrement)
                .accessibilityElement()
                .accessibilityAdjustableAction { direction in
                    switch direction {
                    case .increment:
                        increment()
                    case .decrement:
                        decrement()
                    default:
                        break
                    }
                }
                .accessibilityLabel("\(task.name) frequency")
                .accessibilityValue("\(value) days")
        })
    }
}

关键点:

  • incrementdecrement 是外部传入的增减操作。
  • value 根据任务类型读取浇水或施肥频率。
  • CustomCounter(value:increment:decrement:) 仍然负责视觉界面。
  • .accessibilityElement() 创建一个新的高层级无障碍元素,并忽略子元素。
  • .accessibilityAdjustableAction 把 VoiceOver 的增减手势映射到业务方法。
  • .accessibilityLabel("\(task.name) frequency") 提供控件名称。
  • .accessibilityValue("\(value) days") 提供当前值,值变化时会再次读出。

AssistiveTouch:让静态内容可以被聚焦

19:50)AssistiveTouch 只会聚焦可交互元素。按钮、开关和 NavigationLink 默认可聚焦。普通 TextLabel 默认不会被聚焦。演讲里的饮料信息区域本身可以点击打开详情,但内部静态文本不会自动成为 AssistiveTouch 焦点。

struct FreeDrinkView: View {
    @State var didCancel = false
    @State var didAccept = false
    @State var showDetail = false

    var body: some View {
        VStack(spacing:10) {
            FreeDrinkTitleView()

            FreeDrinkInfoView()
                .accessibilityRespondsToUserInteraction(true)

            HStack {
                CancelButton(buttonTapped: $didCancel)
                AcceptButton(buttonTapped: $didAccept)
            }
        }
        .onTapGesture {
            showDetail.toggle()
        }
        .sheet(isPresented: $showDetail, onDismiss: dismiss) {
            DrinkDetailModalView()
        }
    }
}

关键点:

  • @State var showDetail 控制详情弹窗是否显示。
  • FreeDrinkInfoView() 显示饮料信息。
  • .accessibilityRespondsToUserInteraction(true) 声明这个静态区域会响应用户交互。
  • HStack 内的取消和接受按钮本来就能被 AssistiveTouch 聚焦。
  • .onTapGesture 让整个 VStack 被点击时打开详情。
  • .sheet(isPresented:onDismiss:) 展示详情界面。

AssistiveTouch 光标框:contentShape 决定可点击区域

21:12)AssistiveTouch 光标框和元素的可点击区域一致。一个很小的省略号按钮会得到很小的光标框,内容也可能被裁切。演讲用 contentShape 扩大可点击区域。

struct DrinkView: View {
    var currentDrink:DrinkInfo

    var body: some View {
        HStack(alignment: .firstTextBaseline) {
            DrinkInfoView(drink:currentDrink)

            Spacer()

            NavigationLink(destination: EditView()) {
                Image(systemName: "ellipsis")
                    .symbolVariant(.circle)
            }
            .contentShape(Circle().scale(1.5))
        }
    }
}

关键点:

  • HStack(alignment: .firstTextBaseline) 把饮料信息和编辑入口放在同一行。
  • Spacer() 把编辑按钮推到右侧。
  • NavigationLink(destination: EditView()) 提供进入编辑界面的操作。
  • Image(systemName: "ellipsis") 显示省略号图标。
  • .symbolVariant(.circle) 使用圆形变体。
  • .contentShape(Circle().scale(1.5)) 把可点击区域改成放大 1.5 倍的圆形,AssistiveTouch 光标框也随之变大。

AssistiveTouch 动作菜单:复用 accessibilityAction

22:48)AssistiveTouch 的动作菜单会显示系统动作,也会显示元素上的自定义无障碍动作。已经为 VoiceOver 添加的 accessibilityAction 会自动出现在 AssistiveTouch 菜单里。

PlantContainerView(plant: plant)
    .padding()
    .accessibilityElement(children: .combine)
    .accessibilityAction {
        // Edit action
    } label: {
        Label("Edit", systemImage: "ellipsis.circle")
    }

关键点:

  • PlantContainerView(plant: plant) 显示一个植物单元格。
  • .padding() 给单元格留出触控空间。
  • .accessibilityElement(children: .combine) 把子元素合并成一个无障碍元素。
  • .accessibilityAction 添加自定义动作。
  • Label("Edit", systemImage: "ellipsis.circle") 为动作菜单提供文字和图标。
  • AssistiveTouch 聚焦这个元素时,Edit 会出现在动作菜单前部。

核心启发

  1. 做什么:给 Apple Watch 待办 App 增加大字号布局。 为什么值得做:watchOS 8 支持更大的无障碍文字大小,固定布局很容易截断内容。 怎么开始:把固定 .font(.system(size:)) 改成 .font(.headline).font(.caption2) 等文本样式;读取 @Environment(\.sizeCategory),在大字号下切换到垂直布局。

  2. 做什么:为健身或用药提醒 App 重写 VoiceOver 标签。 为什么值得做:图标默认标签通常描述形状,用户需要听到任务含义。 怎么开始:给状态文本和按钮添加 .accessibilityLabel(...),把 “capsule” 这类图标名改成 “Take medicine at 9 PM” 这类业务文案。

  3. 做什么:把自定义步进器改成 VoiceOver 可调节控件。 为什么值得做:由加号、数值、减号拼出来的控件会让 VoiceOver 导航变慢。 怎么开始:在自定义控件上使用 .accessibilityElement(),再用 .accessibilityAdjustableAction 处理 .increment.decrement

  4. 做什么:检查 watchOS App 的 AssistiveTouch 焦点顺序。 为什么值得做:AssistiveTouch 用户依赖焦点移动和动作菜单完成操作,小按钮和隐藏交互会造成阻碍。 怎么开始:打开 AssistiveTouch,逐个页面测试;对会响应点击的静态区域添加 .accessibilityRespondsToUserInteraction(true),对小按钮使用 .contentShape(...) 扩大可点击区域。

  5. 做什么:把列表单元格的二级操作放入 AssistiveTouch 动作菜单。 为什么值得做:Apple Watch 屏幕小,单元格内放多个按钮会增加导航负担。 怎么开始:把单元格内容用 .accessibilityElement(children: .combine) 合并,再用 .accessibilityAction 添加编辑、完成、删除等动作。

关联 Session

评论

GitHub Issues · utterances