🏷️

アクセシビリティ後回しにしてませんか?Flutterでできる読み上げ対応入門

に公開

はじめに

こんにちは!
株式会社アンドエーアイの荻野と申します!
今回は Flutterで「VoiceOver」や 「TalkBack」といった機能をFlutterで実現する際の知識として、「Semantics」をご紹介します!、


アクセシビリティとは

Accessibilty = 到達可能性

→ ユーザーがアプリケーションのコンテンツに到達できるか

似た概念としてユーザビリティがありますが、
ユーザビリティが「アプリが快適に使えるか」を表す概念であるのに対し、
アクセシビリティは、「そもそもユーザーがコンテンツに到達できるか」を表すという点で意味合いが異なります。


アクセシビリティの重要性

日本では「障害者差別解消法」が改正され、2024年から民間事業者にも合理的配慮が義務化されました。

国際的にもアクセシビリティ規格が多数あり、今日ではアクセシビリティは単なるオプションではなく「全てのユーザに情報を届ける責任」が開発者にも求められるようになりました。

今回は主なアクセシビリティのうち、スクリーンリーダーへの対応について、解説を行います。


Flutterにおけるスクリーンリーダー対応の概要

スクリーンリーダーとは

画面上の文字やUIを音声で読み上げてくれる支援技術です。

Android では 「TalkBack」、iOS では「VoiceOver」という機能がそれぞれ搭載されています。


Semanticsウィジェット

FlutterではSemanticsウィジェットを使用し、UIに意味を付与することで、適切な画面の読上げを実現します。

Semanticsは子(child)の他にあらゆる属性のフラグを持ちます。

Semanticsでパラメータを指定することで、特定のウィジェットにUIパーツの種類や処理の説明を追加できます。

例:
enable → 有効か無効か
button → ボタンかどうか

Semanticsのパラメータ
Semantics({
    Key? key,
    Widget? child,
    bool container = false,
    bool explicitChildNodes = false,
    bool excludeSemantics = false,
    bool blockUserActions = false,
    bool? enabled,
    bool? checked,
    bool? mixed,
    bool? selected,
    bool? toggled,
    bool? button,
    bool? slider,
    bool? keyboardKey,
    bool? link,
    Uri? linkUrl,
    bool? header,
    int? headingLevel,
    bool? textField,
    bool? readOnly,
    bool? focusable,
    bool? focused,
    bool? inMutuallyExclusiveGroup,
    bool? obscured,
    bool? multiline,
    bool? scopesRoute,
    bool? namesRoute,
    bool? hidden,
    bool? image,
    bool? liveRegion,
    bool? expanded,
    int? maxValueLength,
    int? currentValueLength,
    String? identifier,
    String? label,
    AttributedString? attributedLabel,
    String? value,
    AttributedString? attributedValue,
    String? increasedValue,
    AttributedString? attributedIncreasedValue,
    String? decreasedValue,
    AttributedString? attributedDecreasedValue,
    String? hint,
    AttributedString? attributedHint,
    String? tooltip,
    String? onTapHint,
    String? onLongPressHint,
    TextDirection? textDirection,
    SemanticsSortKey? sortKey,
    SemanticsTag? tagForChildren,
    VoidCallback? onTap,
    VoidCallback? onLongPress,
    VoidCallback? onScrollLeft,
    VoidCallback? onScrollRight,
    VoidCallback? onScrollUp,
    VoidCallback? onScrollDown,
    VoidCallback? onIncrease,
    VoidCallback? onDecrease,
    VoidCallback? onCopy,
    VoidCallback? onCut,
    VoidCallback? onPaste,
    VoidCallback? onDismiss,
    MoveCursorHandler? onMoveCursorForwardByCharacter,
    MoveCursorHandler? onMoveCursorBackwardByCharacter,
    SetSelectionHandler? onSetSelection,
    SetTextHandler? onSetText,
    VoidCallback? onDidGainAccessibilityFocus,
    VoidCallback? onDidLoseAccessibilityFocus,
    VoidCallback? onFocus,
    Map<CustomSemanticsAction, VoidCallback>? customSemanticsActions,
    ui.SemanticsRole? role,
  })

アプリのSemanticsを可視化する

iOS(Voice Over)の場合

実機で確認、もしくはシミュレータの場合、Xcodeの Xcode > Open Developer Tool > Accesibility Inspector を使用します。


Android(Talk Back)の場合

実機で確認、もしくはエミュレータの場合、Play StoreからAndroid ユーザー補助設定ツールをインストールします。
https://developer.android.com/codelabs/basic-android-kotlin-compose-test-accessibility?hl=ja#0


アプリ内でSemantics表示する場合

WidgetApp, MaterialApp, CupertinoAppshowSemanticsDebuggerを有効にすることでアプリ内のSemanticsを可視化出来ます。


実装方法

標準のコンポーネントを使用する場合

結論からいうと標準のコンポーネントは、アクセシビリティが厳格に要件があるなどの場合を除いて、そのままでも特に問題ありません。

基本的にパーツ毎にタイプや、内部のTextなどを統合したSemanticsを提供する処理が組み込まれているため、特に対策せずともある程度のアクセシビリティが担保されます。

上の画面では、以下のウィジェットが使用されていますが、SemanticsDebuggerを有効にすると、問題なくSemanticsが設定されていることを確認出来ます。

  • TextFormField
  • IconButton
  • CheckBox
  • Text
  • ElevatedButton


独自のコンポーネントを使用する場合

標準コンポーネント使用時は特に意識せずとも、Semanticsを実装できましたが、独自にコンポーネントを実装する場合は少し工夫が必要です。

以下のような独自ボタンがあったとします。

Card(
    child: InkWell(
        onTap: _doSomething,
        child: Padding(
            padding: const EdgeInsets.all(16.0),
            child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                    Image.asset(
                        'assets/images/logo.png',
                        width: 64,
                        height: 64,
                    ),
                    const Text('Tap Me'),
                ],
            ),
        ),
    ),
),

これをAccessibility Inspectorで見てみると以下のようになりました。

このように、そのまま独自コンポーネントを実装すると、コンポーネントがボタンとして実装されていたとしても、スクリーンリーダ上では本質的でない情報が優先されてしまう可能性があります。


例示したコンポーネントを修正する

ここで必要なのは以下の三点です。
① 不要なSemanticsを削除する。
② ボタンであることを認識させる。
③ どのようなボタンかを説明するためにラベルもしくは名称を設定する。


① 不要なSemanticsを削除する。

不要なSematicsを削除するにはExcludeSemanticsを使用します。
これによりラップされたウィジェット内のSemanticsはスクリーンリーダから認識されなくなります。
今回は余計なものが入らないように、ボタン全体をラップします。

ExcludeSemantics(
    child: ...,
),


② ボタンあることを認識させる。

Semanticsを使用し、buttonを”true”に設定します。
これによりラップされたウィジェットはスクリーンリーダからボタンとして認識されます。

Semantics(
    button: true,
    child: ...
)


③ どのようなボタンかを説明するためにラベルもしくは名称を設定する。

おなじくSemanticslabelを設定します。
これによりラップされたウィジェットを読み上げる際、labelに渡した文言が使用されます。
今回は、ラベルとしてより詳しい説明文を渡します。

Semantics(
    label: '会社HPへ移動',
    button: true,
    child: ...
)


修正結果

上記修正を加えた結果が、こちらになります。

Semantics(
    label: '会社HPへ移動',
    button: true,
    child: ExcludeSemantics(
            child: Card(
                child: InkWell(
                    onTap: _doSomething,
                    child: Padding(
                        padding: const EdgeInsets.all(16.0),
                        child: Column(
                            mainAxisSize: MainAxisSize.min,
                            children: [
                                Image.asset(
                                    'assets/images/logo.png',
                                    width: 64,
                                    height: 64,
                                ),
                                const Text('Tap Me'),
                            ],
                        ),
                    ),
                ),
            ),
        ),
    ),
),

説明とボタンであることを明示できました。


Semanticsの合成

たとえば、ラベル付きのチェックボックスなど、二つのコンポーネントを組み合わせたコンポーネントがあるとします。


このような場合に、それぞれのコンポーネントが別々に読み上げられてしまうと、ユーザーから二つのコンポーネントの関係性を理解してもらえない可能性があります。

そういった場合はMergeSemanticsを使用します。

MergeSemantics(
    child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
            Checkbox(value: _value, onChanged: _onChanged),
            const Text('Check something'),
        ],
    ),
),

修正結果

一つのパーツとして読み上げられるようになりました。


最後に

スクリーンリーダのような支援機能は、大多数の人からすれば必要のない機能であるため、特に要件として定められていないケースも多く、気づかないうち読上げが破綻していることも少なくありません。

しかし、公共性の高いサービスがどんどんデジタル化されていく現在では、可能な限り多くの人が必要な情報にアクセス・アプリを利用できるように、これら支援機能への対応がより求められるようになっていくと考えられます。
そうしたときに、この記事がそのような方々の一助になれば幸いです。

PR

アンドエーアイでは事業拡大のため、即戦力エンジニアを募集中!Flutterだけでなく、インフラ、Web、ネイティブ開発などの知識を持つ方も歓迎します。最新技術を追い、チームに積極的に貢献できる方をお待ちしています!

採用ページ
https://iwantyou.andai.net/

エンジニア採用ページ
https://iwantyou.andai.net/engineer

参考資料

Semantics class

アンドエーアイTechBlog

Discussion