WWDC Quick Look 💓 By SwiftGGTeam
Make your Mac app more accessible to everyone

Make your Mac app more accessible to everyone

观看原视频

Highlight

Mac 应用的无障碍和 iPhone/iPad 有一个关键区别:Mac 的界面更密集,嵌套容器层级更深。VoiceOver 在 Mac 上默认按容器导航,这意味着如果你的 accessibility 元素树结构不合理,用户每走一步都要反复”钻入”和”跳出”容器,体验极其低效。


核心内容

想象一下这样的场景:用户打开你的 Mac 文本编辑器,想去右侧 Format Inspector 里点一下”Bold”开关。视力正常的用户瞄一眼、移一下鼠标就完成了。但 VoiceOver 用户必须按方向键,一个一个穿过 22 个无障碍元素——Title、Apply、Subtitle、Apply、Heading、Apply……才能到达”Bold”。Session 里 Nicholas 录下了这段真实音频,光是听完就让人头皮发麻。

Mac 的根本特征是:键鼠交互、信息密度高、多任务窗口多。这意味着 SwiftUI 默认把每个 view 都当成独立 accessibility element,再加上 VoiceOver 在 Mac 上默认按容器(container)跳转的策略,应用很容易陷入两种极端:要么扁平到每一步都得过一遍所有元素,要么嵌套过深以至于用户在容器之间反复”focus in / focus out”。今年 Apple 给了三件武器来解决这个问题:用 accessibilityElement(children:) 重塑布局结构、用 rotor 与 accessibilityDefaultFocus 加速导航、用 accessibilityAction 给 hover-only 操作补一条键盘等价路径。整场 Session 围绕一个文本编辑器 demo,把这三件事一步步打磨到位。


详细内容

第一步:用 accessibilityElement(children:) 重塑布局04:15)。这个 modifier 提供三种行为:contain 把子视图聚成一个容器、combine 把子视图合并成一个元素、ignore 直接屏蔽子视图。Demo 里 22 个 style preset 元素被压缩成一个名为 “Style Presets” 的 group:

// Contain style presets in accessibility container
struct FormattingInspectorView: View {
  var body: some View {
    Form {
      VStack {
        StylePresetView(type: .title)
        StylePresetView(type: .heading)
        StylePresetView(type: .subHeading)
        StylePresetView(type: .body)
      }
      .accessibilityElement(children: .contain)
      .accessibilityLabel("Style Presets")
    }
  }
}

关键点:

  • .accessibilityElement(children: .contain):把 VStack 变成一个容器,子视图依然是独立元素,但 VoiceOver 默认从外层一跳而过。
  • .accessibilityLabel("Style Presets"):给容器一个清晰的名字,用户听到 “Style Presets, Group” 就知道里面是什么。
  • 改完之后元素总数从 22 降到 15,“Bold” 开关只差几下方向键。

第二步:用 combine 合并成对元素06:21)。每个 style preset 是 HStack { Title, Button },VoiceOver 会分别读出”Title”和”Apply - button”两次。用 .combine 把它们合成一个元素:

struct StylePresetView: View {
  let preset: StylePreset
  var body: some View {
    HStack {
      PresetTitleView(preset: preset)
      Button("Apply") { /* ... */ }
    }
    .accessibilityElement(children: .combine)
  }
}

关键点:

  • .combine 会把子视图的 label、traits、actions 全部合并到一个 element 上。
  • VoiceOver 一次性读出”Title, Apply, button”,操作语义清晰。
  • 元素数量再次减半,从 8 个降到 4 个。

第三步:用 rotor 把”找书签”做成一键跳转08:43)。视力正常的人扫一眼侧栏就能看出哪些页面被加了书签;VoiceOver 用户却要逐页听过去。Rotor 让用户用一个键盘快捷键弹出菜单,里面只列出符合条件的元素:

struct PagesView: View {
  @Binding var pages: [Page]
  var body: some View {
    List(pages) { page in
      PageListItemView(page: page)
    }
    .accessibilityRotor("Bookmarks") {
      ForEach(pages) { page in
        if page.isBookmarked {
          AccessibilityRotorEntry(page.title, id: page.id)
        }
      }
    }
  }
}

关键点:

  • .accessibilityRotor("Bookmarks"):注册一个名为 “Bookmarks” 的转轮。
  • AccessibilityRotorEntry(page.title, id: page.id):每个条目对应一个可跳转目标,id 必须能在 List 中定位到对应行。
  • VoiceOver 在 List 上聚焦后,用 rotor 快捷键弹出菜单,直接跳到 “Page 5 bookmarked”。

第四步:用 accessibilityDefaultFocus 建议初始焦点09:33)。macOS / iOS 26 新增的 modifier,允许应用在新场景出现时建议 VoiceOver 把焦点放到哪个视图上:

struct MyView: View {
  @AccessibilityFocusState(for: .voiceOver) var focusedForVoiceOver

  var body: some View {
    FirstView()
    SecondView()
      .accessibilityDefaultFocus($focusedForVoiceOver, true)
    ThirdView()
  }
}

关键点:

  • @AccessibilityFocusState(for: .voiceOver):只针对 VoiceOver 的焦点状态,不影响键盘焦点。
  • .accessibilityDefaultFocus($focusedForVoiceOver, true):场景出现时建议 SecondView 获得焦点。
  • 仅是”建议”——VoiceOver 可能根据用户偏好做出不同选择,应用不要强假设。

第五步:给 hover-only 操作补 accessibilityAction10:28)。Demo 里的书签按钮只在鼠标悬停页面缩略图时才出现。VoiceOver 用户没有悬停动作,按钮永远摸不到:

struct PageListItemView: View {
  var page: Page
  var body: some View {
    VStack() {
      ThumbnailView(page: page)
      Text(page.title)
    }
    .onHover { /* ... */ }
    .accessibilityAction(named: page.isBookmarked ? "Remove Bookmark" : "Bookmark") {
      page.isBookmarked.toggle()
    }
  }
}

关键点:

  • .accessibilityAction(named:):声明一个有名字的动作,VoiceOver / Voice Control / Switch Control 都能调用。
  • 名字根据当前状态动态变化(“Bookmark” / “Remove Bookmark”),用户清楚下一步会发生什么。
  • 这条 action 不依赖鼠标悬停,键盘和辅助技术都能直接触达。

核心启发

  • 做什么:在项目早期把 VoiceOver 当成回归测试跑一遍。

    • 为什么值得做:Session 中 Nicholas 反复强调”从一开始测”,问题越早暴露成本越低。视觉布局阶段就听一遍 VoiceOver 朗读,能立刻发现元素过多、顺序错乱、容器嵌套过深这类结构问题。
    • 怎么开始:在 macOS 上用 Cmd + F5 打开 VoiceOver,用 Ctrl + Option + 方向键 在新写的 view 上走一遍,把”听完一个完整页面要几次按键”作为团队的可观察指标。
  • 做什么:用 accessibilityElement(children:) 给密集界面分层。

    • 为什么值得做:Mac 应用的 inspector、sidebar、toolbar 普遍包含大量并列控件,扁平结构会让 VoiceOver 用户付出几十次按键才能穿过。contain + accessibilityLabel 几行代码就能把元素总数降一半。
    • 怎么开始:先在最外层 group 用 .contain 配合一个有意义的 .accessibilityLabel,再到内层把语义成对的子视图(Title + Button、Icon + Label)用 .combine 合并;嵌套层级控制在 3 层以内。
  • 做什么:为重要集合加 rotor。

    • 为什么值得做:书签、错误项、待办、未读消息这些”散落在长列表里的少数子集”,是 VoiceOver 用户最容易迷路的地方。Rotor 把 O(N) 的扫描变成 O(1) 的菜单跳转。
    • 怎么开始:找出应用里”用户最常想直接跳过去”的元素类型,给对应的 List / ScrollView 加 .accessibilityRotor("名字") { ForEach { AccessibilityRotorEntry(...) } },再用 VoiceOver 测一下快捷键弹出菜单是否能跳到正确的行。
  • 做什么:审计所有 hover-only 与手势操作,补 accessibilityAction

    • 为什么值得做:Mac 里很多”鼠标悬停才出现的按钮”对 VoiceOver、Switch Control、Voice Control 用户都是隐形控件。一个 .accessibilityAction(named:) 就能让所有辅助技术接入。
    • 怎么开始:grep 项目里的 .onHover.gesture 调用点,逐一确认是否触发了状态变更或导航;如果是,就在同一个 view 上加 .accessibilityAction(named:),名字写成动词短语。

关联 Session

评论

GitHub Issues · utterances