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)使用系统文本样式,文字会跟随系统文字大小变化。PlantImage、PlantTaskList和PlantTaskButtons分别负责图片、任务列表和操作按钮。
(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")
})
}
}
关键点:
increment和decrement是外部传入的增减操作。value根据任务类型读取浇水或施肥频率。CustomCounter(value:increment:decrement:)仍然负责视觉界面。.accessibilityElement()创建一个新的高层级无障碍元素,并忽略子元素。.accessibilityAdjustableAction把 VoiceOver 的增减手势映射到业务方法。.accessibilityLabel("\(task.name) frequency")提供控件名称。.accessibilityValue("\(value) days")提供当前值,值变化时会再次读出。
AssistiveTouch:让静态内容可以被聚焦
(19:50)AssistiveTouch 只会聚焦可交互元素。按钮、开关和 NavigationLink 默认可聚焦。普通 Text 或 Label 默认不会被聚焦。演讲里的饮料信息区域本身可以点击打开详情,但内部静态文本不会自动成为 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会出现在动作菜单前部。
核心启发
-
做什么:给 Apple Watch 待办 App 增加大字号布局。 为什么值得做:watchOS 8 支持更大的无障碍文字大小,固定布局很容易截断内容。 怎么开始:把固定
.font(.system(size:))改成.font(.headline)、.font(.caption2)等文本样式;读取@Environment(\.sizeCategory),在大字号下切换到垂直布局。 -
做什么:为健身或用药提醒 App 重写 VoiceOver 标签。 为什么值得做:图标默认标签通常描述形状,用户需要听到任务含义。 怎么开始:给状态文本和按钮添加
.accessibilityLabel(...),把 “capsule” 这类图标名改成 “Take medicine at 9 PM” 这类业务文案。 -
做什么:把自定义步进器改成 VoiceOver 可调节控件。 为什么值得做:由加号、数值、减号拼出来的控件会让 VoiceOver 导航变慢。 怎么开始:在自定义控件上使用
.accessibilityElement(),再用.accessibilityAdjustableAction处理.increment和.decrement。 -
做什么:检查 watchOS App 的 AssistiveTouch 焦点顺序。 为什么值得做:AssistiveTouch 用户依赖焦点移动和动作菜单完成操作,小按钮和隐藏交互会造成阻碍。 怎么开始:打开 AssistiveTouch,逐个页面测试;对会响应点击的静态区域添加
.accessibilityRespondsToUserInteraction(true),对小按钮使用.contentShape(...)扩大可点击区域。 -
做什么:把列表单元格的二级操作放入 AssistiveTouch 动作菜单。 为什么值得做:Apple Watch 屏幕小,单元格内放多个按钮会增加导航负担。 怎么开始:把单元格内容用
.accessibilityElement(children: .combine)合并,再用.accessibilityAction添加编辑、完成、删除等动作。
关联 Session
- SwiftUI Accessibility: Beyond the basics — 深入讲解 SwiftUI 无障碍修饰符、预览和新 API,适合继续完善本文里的 VoiceOver 支持。
- Tailor the VoiceOver experience in your data-rich apps — 介绍复杂数据场景下如何控制 VoiceOver 读出的主信息和附加信息。
- Support Full Keyboard Access in your iOS app — 从运动辅助输入角度讲解焦点、操作和键盘可达性。
- What’s new in watchOS 8 — 总览 watchOS 8 的平台变化,可作为理解 AssistiveTouch 和 watchOS App 更新的背景。
- The practice of inclusive design — 从设计流程角度补充无障碍开发前的包容性思考。
评论
GitHub Issues · utterances