Accessibility 进阶 22m
SwiftUI 无障碍:超越基础
SwiftUI Accessibility: Beyond the basics
2021年6月11日
一句话判断
SwiftUI 的无障碍 API 在 iOS 15 中终于补齐了和 UIKit 的差距 —— 自定义 VoiceOver 手势、排序和分组、动态字体响应式布局这些”高级”需求现在都有原生方案了,但你得主动用,框架不会帮你自动处理。
这场 Session 讲了什么
Session 深入讲解了 SwiftUI 中超越基础无障碍支持的高级技术。基础无障碍包括 accessibilityLabel、accessibilityHint、accessibilityElement 等,这集聚焦在更复杂的场景。
核心内容:
- 自定义手势与 VoiceOver:当你的 app 使用自定义手势(如双指滑动、长按拖动)时,如何让 VoiceOver 用户也能触发这些操作。
- 无障碍元素的排序(Sort Priority):
accessibilitySortPriority控制 VoiceOver 的阅读顺序,当视觉布局和逻辑顺序不一致时必须手动设置。 - 元素分组(Grouping):把多个相关元素组合成一个 VoiceOver 单元,避免用户逐个听取零散信息。
- 动态字体(Dynamic Type)的响应式布局:不只是文字放大,整个布局要在超大字体下仍然可用。
- 自定义控件的无障碍化:给用
UIViewRepresentable或完全 SwiftUI 自定义的控件添加完整的无障碍支持。
值得深挖的点
自定义手势的 VoiceOver 适配。VoiceOver 模式下,用户的触摸行为被 VoiceOver 拦截(单击选择、双击激活、左右滑动切换元素)。你的自定义手势在 VoiceOver 模式下完全失效。解决方案是用 accessibilityAction 系列修饰符为每个手势提供 VoiceOver 等效操作:.accessibilityAction(named: "删除") { ... } 让 VoiceOver 用户通过”上下滑动选择操作 -> 双击执行”的方式触发。
Sort Priority 的陷阱。VoiceOver 默认按照视觉布局从左到右、从上到下阅读。如果你的布局用了 ZStack 或 overlay,VoiceOver 可能按照 View 声明顺序(代码顺序)而不是视觉层次阅读。accessibilitySortPriority 值越大越先被读,默认值是 0。给关键元素设更高的值确保它们先被读到。
代码片段
自定义手势的 VoiceOver 适配:
import SwiftUI
struct SwipeableCardView: View {
let card: Card
let onSwipeLeft: () -> Void
let onSwipeRight: () -> Void
var body: some View {
CardContent(card: card)
// 视觉用户的手势
.gesture(DragGesture()
.onEnded { value in
if value.translation.width < -50 {
onSwipeLeft()
} else if value.translation.width > 50 {
onSwipeRight()
}
}
)
// VoiceOver 用户的等效操作
.accessibilityElement(children: .combine)
.accessibilityLabel("\(card.title), \(card.description)")
.accessibilityAction(named: "左滑删除") {
onSwipeLeft()
}
.accessibilityAction(named: "右滑收藏") {
onSwipeRight()
}
}
}
排序优先级和分组:
import SwiftUI
struct WeatherRowView: View {
let city: String
let temperature: String
let condition: String
var body: some View {
HStack {
Text(condition) // 天气图标
VStack(alignment: .leading) {
Text(city)
Text(temperature)
}
}
// 无障碍分组:三个元素合并为一个
.accessibilityElement(children: .combine)
.accessibilityLabel("\(city), \(temperature), \(condition)")
// 确保城市名先被读到
.accessibilitySortPriority(1)
}
}
动态字体的响应式布局:
import SwiftUI
struct ResponsiveArticleView: View {
let article: Article
@ScaledMetric(relativeTo: .body) var imageSize: CGFloat = 80
var body: some View {
VStack {
// 图片大小随字体缩放
AsyncImage(url: article.imageURL)
.frame(width: imageSize, height: imageSize)
.clipShape(RoundedRectangle(cornerRadius: 8))
// 文字使用 .layoutPriority 确保在大字体下不被挤压
VStack(alignment: .leading) {
Text(article.title)
.font(.headline)
.layoutPriority(1)
Text(article.summary)
.font(.body)
.lineLimit(3...5) // 允许更多行
}
}
// 大字体时切换为垂直布局
.modifier(DynamicLayoutModifier())
}
}
// 根据字体大小动态调整布局
struct DynamicLayoutModifier: ViewModifier {
@Environment(\.sizeCategory) var sizeCategory
func body(content: Content) -> some View {
if sizeCategory >= .accessibilityLarge {
// 超大字体:纯垂直布局
content
.frame(maxWidth: .infinity)
} else {
// 正常字体:水平布局
content
}
}
}
最佳实践
- 每个自定义手势都必须有
accessibilityAction等效。没有适配的手势对 VoiceOver 用户就是”这个功能不存在”。 - 用
.accessibilityElement(children: .combine)合并相关元素。把”城市名 + 温度 + 天气”合为一句话,比逐个朗读效率高得多。 @ScaledMetric不只是给字体用。图标、间距、图片大小都应该随 Dynamic Type 缩放,否则在大字体下布局会失衡。
还有什么值得关注
AccessibilityFocusState可以编程控制 VoiceOver 的焦点位置,适合表单验证错误时自动聚焦到错误字段。- SwiftUI 的
Toggle、Stepper等控件默认支持 VoiceOver,不需要额外适配。 - Xcode 13 的 Accessibility Inspector 新增了 VoiceOver 模拟器,可以在不开启设备 VoiceOver 的情况下测试阅读顺序。
WWDC 2021