WWDC Quick Look 💓 By SwiftGGTeam
Enhance the accessibility of your reading app

Enhance the accessibility of your reading app

观看原视频

Highlight

Apple 为阅读类 App 提供了文本导航 API(accessibilityNextTextNavigationElementaccessibilityLinkedGroup)和 UITextInput 协议,使离散文本视图和自定义渲染文本都能获得 VoiceOver 逐行导航、连续朗读翻页和文本选择的原生体验。

核心内容

做阅读类 App 的开发者常遇到一个问题:页面由多个独立文本视图拼接而成时,VoiceOver 读到段落末尾就”撞墙”了。用户必须重新寻找下一个焦点,阅读体验被硬生生打断。

06:43)演讲者用旅行指南 App 演示了这个场景。每个段落是一个独立的 UITextView,VoiceOver 的 Lines 转子(rotor)在段落末尾卡住,发出错误提示音。用户无法按行流畅地跨段落阅读。

Apple 的解决思路分两条线。第一条针对标准文本视图:用导航 API 把离散的文本元素在辅助功能树里”缝合”起来。第二条针对自定义渲染文本:完整实现 UITextInput 协议,让扫描图片、手写笔记这类非标准内容也能获得原生文本体验。

跨段落文本导航

07:00)iOS 18 引入了文本导航 API。在 UIKit 中,为每个文本元素设置 accessibilityNextTextNavigationElementaccessibilityPreviousTextNavigationElement,VoiceOver 就能在段落间无缝移动。

08:02)SwiftUI 在 iOS 27 提供了更简洁的方案:accessibilityLinkedGroup(id:in:) 修饰符。同一命名空间(namespace)下相同 ID 的文本元素会自动获得跨元素导航能力。

连续阅读与自动翻页

09:35)分页内容在 Speak Screen 朗读时会在页尾停止。Apple 提供了 .causesPageTurn trait,配合 accessibilityScroll 方法,让辅助技术在读完一页后自动翻页,体验接近有声书。

文本选择与自定义操作

11:16)标准文本视图已支持 VoiceOver 的文本选择。开发者还可以把业务操作(如”保存推荐”)注册到编辑转子(edit rotor)中,让用户在选择文本后直接执行。

自定义文本的辅助功能

14:38)当 App 使用扫描图片、自定义渲染等无法使用标准文本视图的场景时,完整实现 UITextInput 协议可以让这些内容获得与原生文本视图相同的辅助功能体验:逐行触摸探索、转子导航、文本选择。

详细内容

用 UIKit 连接离散文本视图

07:29

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]
            }
        }
    }
}

关键点:

  • accessibilityNextTextNavigationElement 指定 VoiceOver 按行导航时的下一个目标元素
  • accessibilityPreviousTextNavigationElement 指定上一个目标元素
  • 在控制器初始化时遍历所有段落,为每个段落设置双向链接
  • 边界段落(第一个和最后一个)只需设置单向链接

用 SwiftUI 链接文本组

07:59

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)
    }
}

关键点:

  • @Namespace 创建一个命名空间,用于限定链接组的作用范围
  • accessibilityLinkedGroup(id:in:)id 相同且 namespace 相同的文本元素会被视为一个连续的文本块
  • .textSelection(.enabled) 开启文本选择,是获得完整辅助功能体验的前提
  • AppKit 用户可使用 accessibilitySharedTextUIElements 达到类似效果

自动翻页

09:50

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
    }
}

关键点:

  • .causesPageTurn trait 标记页面最后一个元素,Speak Screen 读到这里会自动触发翻页
  • accessibilityScroll(_:) 处理翻页逻辑,返回 true 表示翻页成功
  • UIAccessibility.post(notification: .pageScrolled, argument:) 播报当前页码,让用户知道翻页了
  • 这个 trait 在 UIKit 和 SwiftUI 中都可用

编辑转子自定义操作

11:45

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
    }
}

关键点:

  • UIAccessibilityCustomAction.editCategory 把操作注册到编辑转子,而非通用操作菜单
  • 用户在文本选择状态下通过转子切换模式,能找到这个操作
  • 必须保留 super.accessibilityCustomActions,避免覆盖系统默认操作
  • 操作闭包返回 Bool,表示操作是否成功执行

自定义视图实现 UITextInput

16:10

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? {
        willSet { inputDelegate?.selectionWillChange(self) }
        didSet { inputDelegate?.selectionDidChange(self) }
    }
}

关键点:

  • UITextInteraction(for: .nonEditable) 为不可编辑文本添加系统级选择交互,自动显示选择手柄和高亮
  • selectionRects(for:) 返回选中区域的矩形数组,VoiceOver 用这些矩形绘制焦点框
  • text(in:) 返回指定范围的文本内容,辅助技术用这个方法获取要朗读的文本
  • tokenizer 提供分词器,管理按行、句子、词、字符的导航粒度
  • selectedTextRangewillSetdidSet 通知系统选择变化,触发视觉更新
  • UITextInput 需要完整实现所有方法才能获得全部辅助功能体验

核心启发

为小说阅读器添加跨章节连续朗读

做什么:用 accessibilityLinkedGroup 或导航 API 把段落串起来,再为章节末尾设置 .causesPageTurn,让 VoiceOver 和 Speak Screen 连续播放整章内容。

为什么值得做:阅读器通常把每章拆成多个段落视图做懒加载,VoiceOver 读到段落末尾就”撞墙”了,用户必须重新寻找下一个焦点。跨段落导航让视力障碍用户获得接近有声书的连续阅读体验。

怎么开始:SwiftUI 用 accessibilityLinkedGroup(id:in:),UIKit 用 accessibilityNextTextNavigationElementaccessibilityPreviousTextNavigationElement 连接离散文本视图。

让扫描文档 App 支持文本选择

做什么:扫描版 PDF 或手写笔记完整实现 UITextInput 协议,让图片里的文字也能被 VoiceOver 逐行朗读、被 Speak Screen 连续阅读,还能被用户选中复制。

为什么值得做:扫描文档对视力障碍用户一直是”死物”,无法交互。实现 UITextInput 后,这些非标准内容获得与原生文本视图相同的辅助功能体验。

怎么开始:让自定义视图遵循 UITextInput 协议,添加 UITextInteraction(for: .nonEditable),实现 selectionRects(for:)text(in:)tokenizer

在文本编辑场景添加业务操作

做什么:笔记类 App 给选中的文本添加”加入收藏""生成摘要”等操作,把这些操作注册到编辑转子。

为什么值得做:视力障碍用户在选择文本后,如果要在复杂的菜单里翻找业务操作,体验很差。编辑转子把常用操作放在文本选择后的快捷入口,减少操作路径。

怎么开始:创建 UIAccessibilityCustomAction,设置 categoryUIAccessibilityCustomAction.editCategory,在 accessibilityCustomActions 中返回。

为图文混排页面提供无障碍体验

做什么:杂志类 App 的页面常有文字环绕图片的复杂排版。把文本块用 accessibilityLinkedGroup 链接,图片单独设置描述标签。

为什么值得做:复杂排版下辅助技术可能按错误的顺序遍历内容,图片没有描述标签时会被跳过。正确的链接和标签让整个页面能被按阅读顺序完整遍历。

怎么开始:给文本视图添加 accessibilityLinkedGroup(id:in:) 保持阅读连续性,给图片设置 accessibilityLabel 描述内容。

测试 App 的无障碍阅读体验

做什么:打开 VoiceOver,用 Lines 转子逐行滑动,验证是否能跨段落连续移动。打开 Speak Screen(双指从屏幕顶部下滑),检查朗读是否在页尾自动翻页。

为什么值得做:辅助功能问题往往在正常视觉测试中被忽略。每次 UI 结构调整后都可能破坏无障碍体验,定期测试能及早发现问题。

怎么开始:在设备设置中开启 VoiceOver 和 Speak Screen,用 Lines 转子测试跨段落导航,用 .causesPageTurn 验证自动翻页。

关联 Session

  • 220 - Accessibility Controls — 介绍新的辅助功能控制 API,与阅读体验的交互控制互补
  • 370 - TextKit — 深入文本渲染和排版,自定义文本实现 UITextInput 时需要了解的几何计算基础
  • 278 - UIKit modernization — UIKit 的新特性和现代化实践,包含辅助功能相关改进
  • 269 - SwiftUI — SwiftUI 的新 API,包含 accessibilityLinkedGroup 等辅助功能修饰符的更多用法

评论

GitHub Issues · utterances