WWDC Quick Look 💓 By SwiftGGTeam
読書アプリのアクセシビリティを高める

読書アプリのアクセシビリティを高める

元の動画を見る

ハイライト

Appleは読書系アプリ向けに、accessibilityNextTextNavigationElementaccessibilityLinkedGroupなどのテキストナビゲーションAPIとUITextInputプロトコルを提供しています。これにより、離散的なテキストビューでもカスタム描画テキストでも、VoiceOverの行単位ナビゲーション、Speak Screenの連続読み上げとページ送り、テキスト選択といったネイティブ体験を実現できます。

主要内容

読書系アプリを作る開発者は、よく同じ問題にぶつかります。ページが複数の独立したテキストビューをつなぎ合わせて構成されている場合、VoiceOverは段落の終わりで「壁」に当たります。ユーザーは次のフォーカス先を探し直さなければならず、読書体験がそこで途切れてしまいます。

(06:43) 登壇者は旅行ガイドアプリでこの状況を実演しました。各段落が独立したUITextViewになっており、VoiceOverのLinesローターは段落末尾で止まり、エラー音を鳴らします。ユーザーは段落をまたいで行単位に滑らかに読み進められません。

Appleの解決策は2本立てです。1つ目は標準テキストビュー向けで、ナビゲーションAPIを使って離散的なテキスト要素をアクセシビリティツリー上で「縫い合わせる」方法です。2つ目はカスタム描画テキスト向けで、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メソッドを提供しており、支援技術が1ページを読み終えた後に自動でページ送りできるようにします。体験はオーディオブックに近づきます。

テキスト選択とカスタム操作

(11:16) 標準テキストビューはすでにVoiceOverのテキスト選択に対応しています。開発者は「おすすめを保存」のような業務操作を編集ローター(edit rotor)へ登録し、ユーザーがテキスト選択後に直接実行できるようにもできます。

カスタムテキストのアクセシビリティ

(14:38) アプリがスキャン画像、カスタム描画など標準テキストビューを使えない場面を扱う場合、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も同じテキスト要素は、1つの連続したテキストブロックとして扱われます
  • .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で離散的なテキストビューを接続します。

スキャン文書アプリでテキスト選択をサポートする

作るもの: スキャン版PDFや手書きメモでUITextInputプロトコルを完全実装し、画像内の文字もVoiceOverで行ごとに読め、Speak Screenで連続読書でき、ユーザーが選択してコピーできるようにします。

作る価値: スキャン文書は視覚障害のあるユーザーにとって長く「触れない物」でした。UITextInputを実装すると、この非標準コンテンツはネイティブテキストビューと同じアクセシビリティ体験を得ます。

始め方: カスタムビューをUITextInputに準拠させ、UITextInteraction(for: .nonEditable)を追加し、selectionRects(for:)text(in:)tokenizerを実装します。

テキスト編集場面に業務操作を追加する

作るもの: ノートアプリで、選択テキストに対して「お気に入りに追加」「要約を生成」などの操作を追加し、それらを編集ローターへ登録します。

作る価値: 視覚障害のあるユーザーがテキスト選択後、複雑なメニューの中から業務操作を探すのは大きな負担です。編集ローターはテキスト選択後のショートカット入口に頻出操作を置き、操作経路を短くします。

始め方: UIAccessibilityCustomActionを作成し、categoryUIAccessibilityCustomAction.editCategoryに設定し、accessibilityCustomActionsで返します。

図文混在ページにアクセシビリティを提供する

作るもの: 雑誌系アプリのページには、画像の周囲に文字が回り込む複雑なレイアウトがよくあります。テキストブロックをaccessibilityLinkedGroupでリンクし、画像には個別の説明ラベルを設定します。

作る価値: 複雑なレイアウトでは、支援技術が誤った順序で内容をたどることがあります。画像に説明ラベルがなければ読み飛ばされます。適切なリンクとラベルにより、ページ全体を読書順に完全にたどれます。

始め方: テキストビューにaccessibilityLinkedGroup(id:in:)を追加して読書の連続性を保ち、画像には内容を説明するaccessibilityLabelを設定します。

アプリのアクセシブルな読書体験をテストする

作るもの: VoiceOverをオンにし、Linesローターで行単位にスワイプして、段落をまたいで連続移動できるか確認します。Speak Screen(画面上部から2本指で下へスワイプ)をオンにし、朗読がページ末尾で自動ページ送りするか確認します。

作る価値: アクセシビリティ問題は通常の視覚テストでは見落とされがちです。UI構造を変更するたびに読書体験が壊れる可能性があるため、定期テストで早期発見できます。

始め方: デバイス設定でVoiceOverとSpeak Screenを有効にし、Linesローターで段落横断ナビゲーションをテストし、.causesPageTurnで自動ページ送りを確認します。

関連セッション

  • 220 - Accessibility Controls — 新しいアクセシビリティコントロールAPIを紹介し、読書体験の操作制御を補完します
  • 370 - TextKit — テキスト描画と組版を深く扱います。カスタムテキストでUITextInputを実装するときに必要なジオメトリ計算の基礎になります
  • 278 - UIKit modernization — UIKitの新機能とモダン化の実践。アクセシビリティ関連の改善も含まれます
  • 269 - SwiftUIaccessibilityLinkedGroupなどのアクセシビリティ修飾子の利用例を含む、SwiftUIの新APIです

コメント

GitHub Issues · utterances