WWDC Quick Look 💓 By SwiftGGTeam
Catch up on accessibility in SwiftUI

Catch up on accessibility in SwiftUI

观看原视频

Highlight

SwiftUI 在 iOS 18 新增了条件式无障碍修饰符、动态标签构造器和 App Intent 动作,让辅助技术能直接操作 Widget 和拖放交互。

核心内容

很多开发者以为 SwiftUI 内建控件天然就有无障碍支持,不用再操心——但实际跑一遍 VoiceOver 就会发现:装饰性图形没有标签、评论列表里一堆零散按钮分不清归属、hover 浮层对键盘用户来说步骤繁琐。问题出在”有支持”和”体验好”之间的差距。

SwiftUI 的无障碍体系以 accessibility element 为核心。每个 element 携带 label、trait 和 action 三类信息,VoiceOver 只通过 element 与你的 App 交互。内建控件会自动生成 element,但当你用了自定义视图、形状、或者动态交互时,element 的信息就会缺失或不完整。

今年 iOS 18 补上了几个关键缺口:isEnabled 参数让修饰符可以条件生效,不会覆盖 SwiftUI 已有的默认标签;accessibilityLabel 接受 view 构造器,能在保留原有标签的同时追加动态内容;accessibilityDropPoint 为多落点的拖放场景提供了语义描述;accessibilityAction(named:intent:) 让 Widget 里的自定义动作也能被 VoiceOver 直接触发。这些 API 的共同思路是:在不改变视觉布局的前提下,把辅助技术需要的信息补齐。

详细内容

自动生成的 element 与样式系统的配合

SwiftUI 把你在 body 里声明的视图同时渲染成两个输出:屏幕上的视觉内容和 accessibility element。以 Toggle 为例,SwiftUI 自动把”Comments”文本和开关状态合并成一个 element,label 是”Comments”,trait 包含 isToggle 和 isSelected,action 是 press。VoiceOver 聚焦时会读出”Comments, switch button off”,双击即可切换(03:16)。

关键点:当你用 toggleStyle 修改视觉样式时,element 的 label 和 trait 不会丢失。SwiftUI 的样式系统保证视觉可定制性的同时不破坏无障碍属性(04:04)。

给形状和无标签视图补 label

Shape 视图(比如 Circle)默认不会生成 element。加上 .accessibilityLabel() 后,SwiftUI 会为它创建 element 并设置标签。当视图的 opacity 变为 0 时,element 也会自动从辅助技术中隐藏(07:27)。

struct UnreadIndicatorView: View {
    var isUnread: Bool

    var body: some View {
        Circle()
            .foregroundStyle(.blue)
            .accessibilityLabel("Unread")
            .opacity(isUnread ? 1 : 0)
    }
}
  • Circle() 是纯装饰性形状,默认不产生 element
  • .accessibilityLabel("Unread") 同时完成两件事:创建 element、设置标签
  • .opacity(isUnread ? 1 : 0) 控制可见性,opacity 为 0 时 element 自动隐藏

用 combine 减少导航步骤

评论列表中每条评论有未读标记、正文、收藏按钮、回复按钮。默认 VoiceOver 要逐个读出这些元素,步骤很多。用 .accessibilityElement(children: .combine) 可以把一个 HStack 内所有子视图合并成单个 element(08:02)。

var body: some View {
    HStack {
        UnreadIndicatorView(isUnread: message.isUnread)
        MessageContentsView(message: message)
        Spacer()
        Button(action: favorite) { favoriteLabel }
        Button(action: reply) { replyLabel }
    }
    .accessibilityElement(children: .combine)
}
  • HStack 内四个子视图的 label 会被拼接成一个完整标签
  • Button 的 action 自动变成 custom action,VoiceOver 用”上滑”手势即可选择
  • Spacer 不参与 element 生成,只影响视觉布局

条件式修饰符:isEnabled

收藏按钮用 star.fill 图标,超级收藏用 sparkles 图标。Sparkles 的默认标签是”Sparkle”而不是”Super Favorite”,直接加 .accessibilityLabel("Super Favorite") 会把普通收藏时 star 的正确标签也覆盖掉。iOS 18 的 isEnabled 参数解决了这个问题(10:37)。

var body: some View {
    Button(action: favorite) {
        Image(systemName: isSuperFavorite ? "sparkles" : "star.fill")
    }
    .accessibilityLabel("Super Favorite", isEnabled: isSuperFavorite)
}
  • isEnabled: isSuperFavorite 为 true 时才应用该标签
  • 为 false 时 SwiftUI 回退到 star.fill 的默认标签
  • 避免了手动写 if isSuperFavorite { ... } else { ... } 的条件分支

把 hover 浮层变成 custom action

macOS 上用 hover 显示附件,鼠标操作很快,但 VoiceOver 需要四步:聚焦主视图、触发 hover、导航到浮层子视图、操作完再回到主视图。用 .accessibilityActions 可以把浮层控件提取为 custom action,直接挂在主视图上(13:38)。

var body: some View {
    TripView(trip: trip)
        .onHover { showAttachments = $0 }
        .overlay {
            MessageAttachments(attachments: trip.attachments)
                .opacity(showAttachments ? 1 : 0)
        }
        .accessibilityActions {
             MessageAttachments(attachments: trip.attachments)
        }
}
  • .accessibilityActions 接受与 overlay 相同的视图,提取其中的控件转成 custom action
  • overlay 负责视觉呈现,accessibilityActions 负责辅助技术交互,共享同一套视图逻辑
  • VoiceOver 不需要触发 hover,直接通过 action 菜单操作附件

动态标签构造器

评分有时有、有时没有,不能硬编码。新的 accessibilityLabel 构造器接受一个闭包,闭包参数是当前 element 的已有 label,你可以在前面或后面追加内容(15:16)。

var body: some View {
  TripView(trip: trip)
      .accessibilityLabel { label in
         if let rating = trip.rating {
            Text(rating)
         }
         label
      }
}
  • 闭包参数 label 是 SwiftUI 已有的标签内容
  • Text(rating) 在前,label 在后,VoiceOver 会先读到评分再读到正文
  • rating 为 nil 时不追加任何内容,只保留原有标签

拖放的辅助技术支持

自定义 drop delegate 支持多个落点时,VoiceOver 不知道有哪些位置可以放。.accessibilityDropPoint 为每个落点提供描述(17:42)。

var body: some View {
    CommentAlertView(contact: contact)
        .onDrop(of: [.audio], delegate: delegate)
        .accessibilityDropPoint(.leading, description: "Set Sound 1")
        .accessibilityDropPoint(.center, description: "Set Sound 2")
        .accessibilityDropPoint(.trailing, description: "Set Sound 3")
}
  • 三个 .accessibilityDropPoint 描述了三个落点的位置和含义
  • VoiceOver 拖放时会逐个读出”Set Sound 1""Set Sound 2""Set Sound 3”
  • 没有这个修饰符时,VoiceOver 只知道”drop ready”但不知道落点语义

Widget 里的 App Intent 动作

Widget 视图不实时更新,所以 accessibilityAction 的闭包形式无法使用。iOS 18 新增了接受 App Intent 的重载,VoiceOver 触发动作时直接执行 intent(19:45)。

var body: some View {
    ForEach(beaches) { beach in
        BeachView(beach)
            .accessibilityAction(
                named: "Favorite",
                intent: ToggleRatingIntent(beach: beach, rating: .fullStar))
            .accessibilityAction(
                .magicTap,
                intent: ComposeIntent(type: .photo))
    }
}
  • named: "Favorite" 定义一个自定义动作,VoiceOver 在 action 菜单中显示”Favorite”
  • intent: ToggleRatingIntent(...) 指定双击时执行的 App Intent,Widget 会自动刷新
  • .magicTap 是 VoiceOver 的双指双击手势,映射到拍照的 ComposeIntent

核心启发

  • 做什么:给自定义形状和装饰性图标补 .accessibilityLabel()。为什么值得做:Shape 和纯装饰 Image 默认不生成 element,VoiceOver 用户完全看不到这些信息。怎么开始:全局搜索 Circle()RoundedRectangle()Image(systemName:),逐个检查是否需要标签。

  • 做什么:用 .accessibilityElement(children: .combine) 合并列表行。为什么值得做:合并后 VoiceOver 用户一次就能听到完整信息,按钮自动变成 custom action,导航效率翻倍。怎么开始:找到列表项的 HStack/VStack,在最外层加 .accessibilityElement(children: .combine),用 VoiceOver 走一遍验证。

  • 做什么:把 hover 浮层和动态内容用 .accessibilityActions 和动态 accessibilityLabel 暴露给辅助技术。为什么值得做:hover 交互对鼠标用户流畅,对键盘和 VoiceOver 用户却需要四五步操作;把浮层内容提取为 action 或追加到标签里,一步就能完成。怎么开始:找到所有 .onHover 和条件显示的 overlay,加对应的 accessibility 修饰符。

  • 做什么:给 Widget 添加 accessibilityAction(named:intent:)。为什么值得做:Widget 不支持闭包动作,但 App Intent 动作让 VoiceOver 用户能直接执行关键操作并刷新界面。怎么开始:在 Widget 的 ForEach 循环里,为主要操作添加 .accessibilityAction(named:intent:),同时考虑给 .magicTap 绑定最常用操作。

关联 Session

评论

GitHub Issues · utterances