-
提升阅读 App 的辅助功能体验
了解如何为旁白、朗读屏幕等功能打造出色的阅读体验。探索如何实现直观的文本选择、清晰的行段导航,以及连贯的跨元素和跨页阅读体验。
章节
- 0:01 - Introduction
- 1:26 - Characteristics
- 3:45 - Standard views
- 14:05 - Custom text
资源
- accessibilityNextTextNavigationElement
- editCategory
- accessibilityLinkedGroup(id:in:)
- causesPageTurn
- UITextInput
- Accessibility for UIKit
相关视频
WWDC19
-
搜索此视频…
嗨!
我叫 Josh, 我是一名软件工程师, 在无障碍辅助功能团队工作。 今天,我将介绍 如何让你的长篇文本 或阅读 app 对 Apple 平台 上的所有人都无障碍可用。
阅读长篇内容与导航 UI 有着本质的不同: 它注重的是流畅地在文本中移动, 而不仅仅是在控件等 UI 元素之间跳转。
Apple 的框架内置了 对无障碍文本的支持。 但作为开发者,你还可以 做更多工作来丰富 和扩展长篇文本的 无障碍辅助体验。
今天,我将分享一些 最佳实践和技巧, 供你在构建长篇内容时参考。
首先,我将介绍 出色阅读体验的特征, 让使用 VoiceOver 或其他辅助技术的用户受益。 然后,我将展示如何使用 以及扩展 UIKit 和 SwiftUI 中的视图, 利用专为阅读体验设计的 丰富 API。
最后,我将介绍如何让 app 中的 自定义文本对 VoiceOver、朗读屏幕 或无障碍阅读器无障碍可用。
首先,我将讨论 哪些因素能带来出色的无障碍体验, 适用于展示长篇内容的 app。 今天,我想构建一个 app, 用来分享我的旅行推荐和旅行小贴士, 介绍我最喜爱的城市之一:芝加哥。
我的 app 包含分页内容, 有多个段落和文本 跨越多行排列。 我希望确保任何使用 辅助技术的用户 都能获得出色的体验。 在本节中,我将 重点介绍两种常见的 Apple 平台内置 辅助技术: VoiceOver 和朗读屏幕。 VoiceOver 是 Apple 内置的屏幕阅读器, 专为视力障碍或 低视力人群设计。 开启后,我可以听到 光标高亮处的内容。 早晨。 标题。 我们早上从 林肯公园出发, 漫步在林间小径, 欣赏芝加哥天际线的美景。
朗读屏幕功能会大声朗读 给定页面上的所有内容, 从上到下, 朗读时同步高亮显示。 开启后,我可以用两根手指 从屏幕顶部向下滑动 来启动该功能。 正午。 午餐时, 我们沿着芝加哥河漫步。 河滨步道让我们欣赏到 这座城市壮观建筑的绝佳景色。 最美的视角是在 杜萨布尔桥中央, 可以直视河流延伸至远方。
考虑到这些技术, 我设定了三个目标, 以改善 这些功能与我的 app 之间的交互。 具体来说,我希望确保 我的 app 提供精细化的文本导航, 让 VoiceOver 和朗读屏幕 能流畅地在文本中移动。 我还希望确保 提供连续的阅读体验, 让使用辅助技术的用户 不会遇到任何中断。 最后,我希望确保 我的 app 提供全面的文本选择功能。
我将在本视频的其余部分 专注于这些主要目标, 并确保我的旅行 app 全部满足这些目标。
Apple 的框架提供了 许多文本组件, 开箱即用,具备无障碍支持, 所以现在我将重点介绍 它们是什么、能为你提供什么, 以及如何通过额外功能 来扩展它们。 UIKit 和 SwiftUI 都提供了 无障碍文本视图,支持按行、按词 以及按字符导航, 配合 VoiceOver 和朗读屏幕, 同时支持无障碍文本选择。 你可能已经熟悉 UIAccessibilityReadingContent, 这是让整页内容 无障碍可用的好方法。 虽然今天我不会 重点介绍该协议, 你仍然可以在我今天讲的 所有内容之上使用和采用它。 要了解更多,请查看 "创建无障碍阅读体验"。 今天,我将重点介绍 UITextInput, 这是原生文本视图使用的 高保真协议, 你也可以在自定义视图上采用它。
系统中的标准文本视图 均采用了 UITextInput 协议。 在 UIKit 中,使用 iOS 上的 UITextView 将立即为你提供丰富的文本体验, 开箱即用, SwiftUI 中的 TextEditor 也同样如此。 你甚至可以使用简单的 SwiftUI Text 视图并启用选择功能, 在所有 Apple 平台上 享受这些特性。 对于构建 macOS app 的开发者, 使用 AppKit 的 NSTextView, 或所讨论的 SwiftUI 视图, 同样可以获得这些优势。 在 app 的约束条件允许时, 你应该始终尝试使用这些组件。
在我的旅行指南 app 中, 我选择使用 UITextView 来处理每个独立段落, 以获得其无障碍属性。 我设计的独特布局要求我 为每个段落使用单独的文本视图, 而不是使用一个 包含多个段落的视图。 我将首先评估 我在实现目标方面的进展, 即提供精细化的文本导航。
VoiceOver 有一项设置, 允许用户选择 在屏幕上触摸时 朗读的文本粒度, 触摸屏幕时读出的内容粒度。 我将偏好设置为按行, 因此开启 VoiceOver 后, 我可以点击屏幕上的任意行 并听到该行内容被朗读出来。 我们早上从 林肯公园出发, 漫步在林间小径, 欣赏…… VoiceOver 还提供了选项 来更改其移动方式, 通过一项名为转子的功能。 可以通过 使用两指旋转手势 来切换当前转子模式。 现在,我将使用该手势 切换到"行"转子, 并用一根手指向下轻扫 以找到页面上的下一行。
行。
……欣赏芝加哥天际线的美景。
现在,我将尝试从 这个段落的末尾移动 到下一个段落的第一行。
目前,由于这些每个段落 目前,由于这些段落 是各自独立的视图, VoiceOver 只能在段落内 按行导航, 用户无法按行 完整浏览整个页面, 这就是播放该音效的原因。
为了让 VoiceOver 能在段落间无缝移动, iOS 18 引入了 文本导航 API。 对于每个你想连接的 文本元素, 返回 VoiceOver 应导航到的 下一个和上一个 无障碍文本元素。
例如,如果我有 两个段落视图, 我可以从段落 1 的 accessibilityNextTextNavigationElement 方法返回段落 2, 并从段落 2 的 accessibilityPreviousTextNavigationElement 返回段落 1。
这里是我的旅行指南 app 中页面的控制器。 在设置阶段,当 configureNavigationElements 代码路径运行时, 我在适用的情况下 为每个方向设置了正确的导航元素。
现在我已经实现了这个功能, VoiceOver 就可以移动到 一个段落的末尾之后, 接着跳到下一段落的第一行。 离开公园前, 我们特地去免费动物园 看了所有……
如果你使用 SwiftUI, 从 iOS 27 开始, 将多个文本元素链接在一起 使用 accessibilityLinkedGroup 修饰符 可以实现相同的效果。 例如, 我这里有一个等效的页面视图, 包含两个可选择的文本元素。 通过将它们都与 accessibilityLinkedGroup 链接, 使用相同的 id 和命名空间, 它们就会获得 文本导航行为。 如果你在 Mac 上使用 AppKit, 请查看 accessibilitySharedTextUIElements, 可以实现类似效果。 现在我知道 VoiceOver 可以在我 app 的各个页面中导航, 支持不同的文本粒度, 没有任何意外间断。 但我还设定了目标, 确保我 app 的 连续阅读体验尽可能流畅。
分页内容从本质上来说 需要在页面之间滑动切换。 目标是让辅助技术 能无缝地与这些内容互动, 而不被页面打断。 VoiceOver 和朗读屏幕 都具备相应功能, 允许用户阅读所有内容, 从头到尾, 无需滑动翻页。
我将使用朗读屏幕 探索当前的 app 体验。 要全部朗读,我将用两根手指 从屏幕顶部向下滑动。 正午。 午餐时, 我们沿着芝加哥河漫步。 河滨步道让我们欣赏到 这座城市壮观建筑的绝佳景色。 最美的视角是在 杜萨布尔桥中央, 可以直视河流延伸至远方。 你会注意到 朗读屏幕停止了朗读, 当它到达页面底部时。 对于分页内容, 全部朗读的最佳体验 应该是浏览所有页面, 在适当时机翻页, 类似有声书的体验。
这里是我的 app 页面视图控制器。 在我的 viewDidLoad 重写中, 我可以将 causesPageTurn 特征应用于页面上的最后一个段落, 这在 UIKit 和 SwiftUI 中 都可以使用。 与 accessibilityScroll 配合使用时, 朗读屏幕和 VoiceOver 将在到达末尾时自动滚动页面。 当它到达末尾时。
我将尝试在最后一个段落 应用该特征后使用朗读屏幕。
正午。 午餐时, 我们沿着芝加哥河漫步。 河滨步道让我们欣赏到 这座城市壮观建筑的绝佳景色。 最美的视角是在 杜萨布尔桥中央, 可以直视河流延伸至远方。 傍晚。 结束这一天的行程, 我们沿着湖滨漫步, 与一群跑步者和骑行者同行。 这让我们再次欣赏到 天际线的壮观景色, 高耸于密歇根湖的湖水之上。
太棒了! 朗读屏幕在读完后 自动将焦点移到了下一页, 正是我期望的效果。
如果你还记得之前说的, 我最后想验证的行为 是文本选择在 VoiceOver 中的工作方式。 在我的 app 中,我添加了一个功能, 用于保存选中的内容以备日后参考, 通过工具栏中的按钮实现。 我需要确保 这个功能是无障碍可用的。
我这里使用的是 UITextView, 它已经支持无障碍选择。 使用 TextEditor 或在 SwiftUI 中启用选择的文本 也会获得相同的体验。 但我也希望用户能够 发现这个"保存推荐"功能, 针对他们选中的文本使用。 在视觉上, 我在工具栏中添加了这个按钮, 用于保存当前选择, 但我可以通过编辑转子 让 VoiceOver 更容易发现这个功能。 为此,我可以创建一个自定义操作 并将其添加到 VoiceOver 的编辑转子中, 在构建操作时 指定编辑类别。 在我的例子中,我将重写 accessibilityCustomActions, 在我的段落 UITextView 子类上, 并添加我的 "保存推荐"自定义操作, 与父类实现中的 任何操作并列。 当你有与文本选择关联的自定义操作时, 请务必使用编辑类别, 而不是使用通用操作。 而非通用操作。
现在,我要开启 VoiceOver 来试试效果。 要选择文本,我将切换到文本选择转子, 切换到单词编辑模式, 并通过向右滑动 来扩大我的选择范围。
文本选择。 向右滑动以扩大选择范围。 向左滑动以缩小选择范围。 单词选择。 已选中"我们最喜欢的视角是在 杜萨布尔桥……"。
选中文本后, 我将切换到编辑转子, 并激活"保存选择"操作 来保存它。
行。 词。 字符。 编辑。
保存选择。 文本已保存。 太棒了! 现在,我使用系统文本视图 拥有了一个无障碍的 app 体验, 并通过采用 API 解锁了 一套新的无障碍阅读功能。 跨元素的按行导航、连续阅读, 以及文本选择, 都如我所预期的那样工作。 最棒的是, VoiceOver 和朗读屏幕 并不是唯一受益于 这些改变的技术。 自 iOS 26 起, 用户可以打开无障碍阅读器, 这是一个专为 更方便地阅读文本内容而设计的工具。 我已将阅读器控件 添加到我的控制中心, 因此点击它会在无障碍阅读器中 打开我 app 的内容。 像我迄今为止分享的那样 实现无障碍文本实践, 也会提升你的内容 在阅读器中的体验。
这就是让标准文本视图 对阅读内容无障碍的方法, 虽然我始终建议 优先使用这些视图, 但并非所有情况都允许这样做。
现在,我将重点介绍 当你在 app 中使用自定义文本, 或自定义文本元素时, 应该如何使其无障碍可用。
使用自定义文本是 专用阅读 app 中的常见模式, 用于支持高级排版, 在开发者的多个应用程序中 共享代码, 或显示扫描的页面。 旅行时,我喜欢用手写笔记 记录所到之处, 因此我决定将文本视图替换为 从笔记本扫描的页面, 以增添更多个人色彩。 遗憾的是,这意味着我失去了 UITextView 免费提供的无障碍行为, 包括最基本的功能: 朗读文本。 早晨。 标题。 图像。
让这些内容无障碍可用的 最佳方式 是使用 UITextInput 协议, 它可以在任何 无障碍元素上采用。 这个协议可以让渲染的文本 或图像中的文本,例如, 像在标准文本视图中一样 具备完整的无障碍能力。 完整实现 UITextInput 可以为你提供与使用 原生文本视图相同的文本体验。 你将获得 VoiceOver 的 逐行触摸探索, VoiceOver 转子和朗读屏幕的 精细导航, 以及文本选择功能。
要实现这个协议, 你需要解决一些问题。 你需要管理文本的几何结构, 并计算给定范围的 选择矩形, 例如在 selectionRects 方法中。
当辅助技术查询 你视图中的某个范围时, 你需要能够只返回 那部分文本。 重要的是, 你需要提供一个分词器, 它将帮助管理按行、按句、 按词或按字符的导航。
这些只是你需要 实现的部分内容。 要获得这个协议的 全部无障碍优势, 请确保完整实现它。
在我的 app 中,我在无障碍元素上 实现了这个协议, 使文本无障碍可用。 在这里,我实现了 该协议的 selectionRects 方法, 它决定了 VoiceOver 和其他辅助技术 如何"高亮"我的内容。 由于我处理的是 图像中的手写文字, 我可以使用每行已知的 高度和宽度, 来计算近似的矩形, 对于给定范围使用 自定义函数 selectionRectFromImage。 然后我使用这些信息 构建我的选择矩形数组, 并从该方法返回。
我还将完成 协议中其余方法的实现, 例如获取 textInRange 的正确子字符串 以及提供分词器。 在我的情况中,我子类化了 UITextInputStringTokenizer, UIKit 提供的类, 以创建一个自定义分词器, 与我的实现配合工作, 因此我将返回它。
最后,我希望我的选择体验 在视觉上感觉完整, 带有选择手柄和高亮显示。 为此,我在页面视图中 添加了一个 UITextInteraction, 并在选择更改时 调用输入委托, 以便系统知道需要更新视觉效果。 这不是 UITextInput 实现 的必要部分, 但通过匹配用户对 标准文本视图体验 的预期来完善我的 app。
UITextInput 也可以与 causesPageTurn 很好地配合使用, 以及导航元素 API, 因此我确保在我 app 的 新版本中也实现了这些功能。
我已经完成了 app 的更新, 花时间仔细实现了 UITextInput 协议的其余部分, 应用于我扫描的文本, 并确保我实现了 所有必要的 API。 现在,我将来看看 VoiceOver 体验。
首先,我将按行导航。 行。 我们早上从林肯公园出发, 欣赏着 芝加哥天际线的美景。 动物园里有很多动物。
现在,我将切换到文本选择转子 来选择一些文本, 并向右滑动。 文本选择。 向右滑动以扩大选择范围。 向左滑动以缩小选择范围。 行选择。 已选中"动物园里有很多动物"。 行。 词。 字符。 编辑。 保存选择。 文本已保存。
最后,我可以尝试全部朗读。 早晨。标题。 我们早上从 林肯公园出发, 欣赏着芝加哥天际线的美景。
正午。标题。 午餐时, 我们沿着芝加哥河漫步。 从杜萨布尔桥看到的景色 非常适合拍照! 太棒了! 一切都无缝运行。 现在我已经介绍了 出色阅读体验的要素, 以及实现它的 API, 是时候审视你自己的 app 了。
开启 VoiceOver 审查你的 app, 试试全部朗读手势, 使用行转子导航, 以及选择文本。 如果你使用标准文本视图, 考虑采用 causesPageTurn 以及文本导航元素 API, 以实现流畅的跨页阅读行为。 如果你使用自定义渲染文本, 请采用 UITextInput。 完成这些工作将 为每位下载你 app 的用户 带来出色的体验。 祝阅读愉快!
-
-
7:29 - Link text elements together with navigation APIs
// Link text elements together with navigation APIs import UIKit class TravelGuidePageController: UIViewController { var paragraphs: [TravelGuideParagraph] func configureNavigationElements() { for (index, paragraph) in paragraphs.enumerated() { if index + 1 < paragraphs.count { paragraph.accessibilityNextTextNavigationElement = paragraphs[index + 1] } if index - 1 >= 0 { paragraph.accessibilityPreviousTextNavigationElement = paragraphs[index - 1] } } } } -
7:59 - Link text elements together with a linked group
// Link text elements together with a linked group import SwiftUI struct PageView : View { @Namespace private var pageNamespace var paragraphs: [String var pageNumber: Int var body: some View { Text(paragraphs[0]) .textSelection(.enabled) .accessibilityLinkedGroup(id: pageNumber, in: pageNamespace) Text(paragraphs[1]) .textSelection(.enabled) .accessibilityLinkedGroup(id: pageNumber, in: pageNamespace) } } -
9:50 - Turn pages automatically after reading
// Turn pages automatically after reading import UIKit class TravelGuidePageController: UIViewController { override func viewDidLoad() { super.viewDidLoad() self.lastParagraphView.accessibilityTraits.insert(.causesPageTurn) } override func accessibilityScroll(_ direction: UIAccessibilityScrollDirection) -> Bool { moveToPage(direction) var scrollString = "Page \(currentPage) of \(pages.count)" UIAccessibility.post(notification: .pageScrolled, argument: scrollString) return true } } -
11:45 - Add actions to the editor rotor
// Add actions to the editor rotor import UIKit class TravelGuideParagraph: UITextView { override var accessibilityCustomActions: [UIAccessibilityCustomAction]? { get { let saveAction = UIAccessibilityCustomAction(name: "Save Recommendation") { _ in self.saveRecommendation() } saveAction.category = UIAccessibilityCustomAction.editCategory return (super.accessibilityCustomActions ?? []) + [saveAction] } set { } } private func saveRecommendation() -> Bool { ... return true } } -
16:10 - Adopt UITextInput
// Adopt UITextInput import UIKit class ScannedPage: UIView, UITextInput { override init(frame: CGRect) { super.init(frame: frame) let interaction = UITextInteraction(for: .nonEditable) interaction.textInput = self addInteraction(interaction) } func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { var rects: [UITextSelectionRect] = [] let startLine = lineIndex(for: range.start) let endLine = lineIndex(for: range.end) for line in startLine...endLine { let rect = selectionRectFromImage(for: range, in: line) rects.append(rect) } return rects } func text(in range: UITextRange) -> String? { let nsRange = nsRange(from: range) guard let range = Range(nsRange, in: scannedText) else { return nil } return String(scannedText[range]) } var tokenizer: any UITextInputTokenizer { CustomHandwritingTokenizer(textInput: self) } weak var inputDelegate: UITextInputDelegate? var selectedTextRange: UITextRange? { // Update visuals when assistive technologies change selection willSet { inputDelegate?.selectionWillChange(self) } didSet { inputDelegate?.selectionDidChange(self) } } }
-
-
- 0:01 - Introduction
What makes reading apps an accessibility challenge distinct from UI navigation, and what the session covers — the characteristics of a great reading experience, extending UIKit and SwiftUI text views, and making custom text accessible.
- 1:26 - Characteristics
Reading apps present unique accessibility challenges distinct from standard UI navigation, requiring fluid movement through text for technologies like VoiceOver and Speak Screen. This session covers three goals — granular navigation, continuous reading, and text selection — using UIKit, SwiftUI, and AppKit APIs.
- 3:45 - Standard views
UITextView, SwiftUI's TextEditor and selectable Text, and NSTextView on macOS all adopt UITextInput automatically, providing line, word, and character navigation and accessible text selection. The accessibilityNextTextNavigationElement and accessibilityPreviousTextNavigationElement APIs (and the new accessibilityLinkedGroup for SwiftUI) connect separate text elements so VoiceOver can move between them seamlessly, while the causesPageTurn trait provides page turning automatically during read-all gestures.
- 14:05 - Custom text
When using custom or custom-rendered text — such as scanned images — adopting the full UITextInput protocol gives VoiceOver and Speak Screen the same granular navigation and selection capabilities as native text views. This requires implementing text geometry methods like selectionRects(for:), a tokenizer, and text range methods, and can be paired with UITextInteraction for visible selection handles.