提升阅读 App 的辅助功能体验
Enhance the accessibility of your reading app
2026年6月10日
一句话判断
✅ 这场 Session 最值得关注的一件事是:Apple 终于把多段落离散文本的 VoiceOver 焦点连贯性问题解决了,并且让自定义渲染的文本(如扫描图或 Canvas 绘制的字)能通过实现 UITextInput 获得原生级别的逐词朗读和选择体验。
这场 Session 讲了什么
做阅读类 App 的同事都知道,用多个 UITextView 或 Text 拼凑长页面时,VoiceOver 读到段落末尾就会“撞墙”,用户必须重新寻找下一个焦点。如果是自己用 CoreText 或 Canvas 画的文本,辅助功能更是直接瘫痪,只能给整张图塞一个 accessibilityLabel 让用户听一大段天书。
这次 Apple 给出了一套非常务实的解法。在标准组件层面,推出了 accessibilityLinkedGroup (SwiftUI) 和文本导航指针 (UIKit),把离散的文本视图在无障碍树里“缝合”起来。在自定义渲染层面,明确了只要你的自定义 View 完整实现 UITextInput 协议并挂载 UITextInteraction,VoiceOver 和朗读屏幕 (Speak Screen) 就能像对待原生文本框一样,提供逐行触摸探索和光标选择。
这对做小说阅读器、PDF 批注、扫描文档或者复杂图文排版 App 的团队影响最大。你不再需要为了无障碍去重构 UI 架构,只需在现有渲染逻辑上补齐几何计算和分词器即可。
值得深挖的点
缝合离散文本的“隐形拉链”
以前为了 VoiceOver 连贯,开发者被迫把所有文本塞进一个巨大的 UITextView 或者用 UIAccessibilityReadingContent hack,导致排版灵活性尽失。现在 Apple 允许你在保持 UI 组件离散(比如每个段落一个 View,方便做复杂的图文混排和懒加载)的同时,在无障碍树上把它们链接成一条线。
这里的 Trade-off 很明显。UIKit 的方案需要开发者手动维护双向链表(Next/Previous),一旦页面动态增删段落,链表断了,VoiceOver 就会卡死。SwiftUI 的 accessibilityLinkedGroup 聪明些,用 Namespace 和 ID 自动处理,但要求你的文本块必须在同一个视图层级里。如果你做的是类似 UITableView 的按需加载长列表,这套 API 用起来的边界条件会非常恶心,需要你在 Cell 复用逻辑里小心维护链接状态。
让图片里的字“活”过来
这是本次 Session 技术含量最高的部分。很多 App 的文本是渲染在图片上(如扫描版 PDF)或用自定义引擎画的。以前这些内容对视力障碍用户就是死物。现在只要你实现 UITextInput,提供 selectionRects(for:) 返回选中区域的矩形数组,并提供一个分词器 (Tokenizer),系统就会接管剩下的事。
好在哪?你直接获得了系统级的文本选择手柄 (Selection handles) 和转子 (Rotor) 导航,不用自己画 UI。坑在哪?UITextInput 是个极其庞大的协议,尤其是处理中英混排、从右向左 (RTL) 语言以及复杂排版时的几何计算,selectionRects 的返回值稍微算错一点,VoiceOver 的焦点框就会飞到屏幕外面去。建议先用 UITextInteraction 把视觉上的选择手柄调对,再死磕无障碍。
代码片段
1. SwiftUI 缝合多段落文本
场景:页面由多个独立的 Text 组成,需要让 VoiceOver 在段落间无缝按行滑动。
import SwiftUI
struct PageView: View {
// 定义命名空间,用于链接同页面的文本
@Namespace private var pageNamespace
var paragraphs: [String]
var pageNumber: Int
var body: some View {
VStack {
Text(paragraphs[0])
.textSelection(.enabled)
// 将文本加入同一个链接组
.accessibilityLinkedGroup(id: pageNumber, in: pageNamespace)
Text(paragraphs[1])
.textSelection(.enabled)
// ID 和 Namespace 相同即可自动串联
.accessibilityLinkedGroup(id: pageNumber, in: pageNamespace)
}
}
}
坑:如果段落是异步加载或动态插入的,确保 id 和 Namespace 的生命周期稳定,否则链接会意外断开。
2. 触发朗读屏幕自动翻页
场景:分页阅读 App,希望“朗读屏幕”读到页尾时自动翻到下一页。
import UIKit
class TravelGuidePageController: UIViewController {
@IBOutlet weak var lastParagraphView: UITextView!
override func viewDidLoad() {
super.viewDidLoad()
// 给页面最后一个文本元素打上自动翻页标记
lastParagraphView.accessibilityTraits.insert(.causesPageTurn)
}
// 系统读到底部时会自动调用此方法
override func accessibilityScroll(_ direction: UIAccessibilityScrollDirection) -> Bool {
moveToPage(direction)
let scrollString = "第 \(currentPage) 页,共 \(pages.count) 页"
// 播报翻页状态
UIAccessibility.post(notification: .pageScrolled, argument: scrollString)
return true
}
}
坑:.causesPageTurn 必须且只能加在页面的最后一个可访问元素上,加多了会导致朗读屏幕陷入死循环或提前翻页。
3. 为自定义渲染文本接入系统级选择
场景:自定义 View(如扫描件或 Canvas 绘制的文本),需要支持 VoiceOver 的文本选择和长按手柄。
import UIKit
class ScannedPageView: 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 {
// 根据你的排版引擎计算具体的 CGRect
let rect = calculateRectForLine(line, in: range)
rects.append(CustomSelectionRect(rect: rect))
}
return rects
}
// ... 还需要实现 UITextInput 的其他几十个字词/范围计算方法
}
坑:selectionRects 返回的数组必须严格对应视觉上的高亮区域。如果跨行选择,必须拆分成多个 Rect 返回,不能直接返回一个包围所有行的巨大矩形,否则 VoiceOver 的焦点框会遮挡中间未选中的内容。
最佳实践
- 已有项目的迁移策略:先排查现有 App 里的长文本页面。如果是多个 TextView 拼的,立刻加上
accessibilityLinkedGroup或配置 Next/Previous 指针。如果是自绘文本,评估是否有必要上UITextInput;如果只是为了让盲人“听”内容,先考虑accessibilityLabel结合UIAccessibilityReadingContent,成本更低。 - 新项目的采用建议:排版允许的话,无脑用
TextEditor或单个大UITextView。必须拆分 UI 时,优先使用 SwiftUI 的 Linked Group,把脏活累活交给框架。 - 实战避坑:如果你给文本选择添加了自定义操作(比如“高亮”、“保存”),在构建
UIAccessibilityCustomAction时,一定要设置category = .editCategory。如果不设置,这个操作只会出现在通用的 Actions 菜单里,而不会出现在 VoiceOver 文本编辑专属的转子 (Rotor) 中,用户根本找不到。
还有什么值得关注
- 无障碍阅读器 (Accessibility Reader):iOS 26 新增的系统级工具,只要你的文本无障碍属性配置规范,用户可以在控制中心一键唤起,系统会提取内容并重新排版为极简阅读模式。
- 编辑转子 (Editor Rotor) 增强:现在可以把业务逻辑(如“加入生词本”)无缝塞进 VoiceOver 的文本编辑上下文菜单中,极大提升了视力障碍用户的操作效率。
- 自动翻页 Trait:
.causesPageTurn配合accessibilityScroll,让“朗读屏幕”体验直接追平专业的有声书 App,不用再手动干预翻页逻辑。