😀

アクセシブルFlutter: Semantics入門

2022/08/20に公開
2

Flutter界隈の皆さんこんにちは。mjhdです。
今日は、FlutterのSemanticsについてのお話をしたいと思います。

以下のようなウィジェットを見かけること、あるんじゃないでしょうか?

Semantics(
  container: true,
  label: 'hoge',
  child: ...,
)

何のためにあるのか分からないままコピペしたり、よく分からないので消してしまったり、そんな経験みなさんもあると思います。

この記事では、これらのSemanticsウィジェットが何のためにあるのか、どうに使うべきなのかを解説していきたいと思います。

Flutterの設計を見るとSemanticsは大事らしい

何のためにあるのか分からないまま消してしまうこともあるSemantics、実はとても大事なものらしいのです。

Flutterの内部実装を読んでみると、FlutterはSemantics機能に注力しており、我々が書いたウィジェットは「描画エンジン」と「支援技術」の2つに送られるよう設計されています。

Layoutフェーズや, Paintフェーズ, Layer Treeの構築などをした後、描画データと一緒にSemantics情報をEngineへ送信しています。
https://github.com/flutter/flutter/blob/c7498f607b116f6e8e2165f1ed9bef19bf022bf7/packages/flutter/lib/src/rendering/binding.dart#L514

UIの描画と同じぐらい、Semanticsも大事に扱われています…UIの描画が大切なのは理解しやすいですね、UIが表示されなかったら何の意味もありません。真っ白な画面が表示されるだけです。
では、Semanticsはどのような意味を持つのでしょうか?

Semanticsを説明するために、まずはアクセシビリティという言葉について深掘りしてみましょう。

アクセシビリティとは何だろう?

アクセシビリティという言葉は、一度は耳にしたことがあると思います。
辞書で引いてみると「アクセス可能性」「到達可能性」などと訳されています。
そしてアクセシビリティという用語はもちろん、Webサイトやアプリにのみ使われる技術用語ではなく、物理的な施設や看板や標識、あらゆるものに使える言葉です。

例えば、障害者権利条約という国際条約の日本語訳(障害者の権利に関する条約)では、「Article9 Accessibility」は「第九条 施設及びサービス等の利用容易さ」と訳されています。

このようにアクセシビリティという言葉自体はさまざまな対象に対して適用されますが、共通する意味合いは何でしょうか?

(ここからは私感です。ですが、「なぜアクセシビリティをやるのか」という理由はSemanticsを扱う上でとても大切だと思うため、少し長めに説明します)

我々開発者は、ユーザに何かを伝えるためにサービスを開発しています。
例えば、写真ギャラリーアプリであれば、写真の美しさや社会的風刺性などをユーザに伝えるために分かりやすいUIを考えたり、写真をできるだけ大きく表示するなど工夫をしますね。

こうした、サービスがユーザに伝えたい本質的な情報がどれほど伝わりやすいか、という要素がアクセシビリティだと私は考えています。

同心円状に、中央にはサービスの本質があり、その外側にSemanticsやUI、更に外側に支援技術や画面、翻訳や眼鏡、環境音や環境光が配置され、ユーザから中央のサービスの本質に向かって様々な障壁を経て到達する様子を表した図

さまざまなユーザがサービスを使用するとき、さまざまな要因がサービスの本質を伝えるための障壁となります。
例えば、いかに分かりやすいUIを作るか、いかに視認性の高いロゴを作るか、いかに平易な文章をサービス中で使うか、などもこの障壁を取り払い、ユーザに本質的な情報を伝えるための工夫です。これも一種のアクセシビリティであると思います。

この点で重要なのが、アクセシビリティという度合はユーザによって異なるという点です。
例えば、サービスに関するドメイン知識のないユーザには、本来伝えたい内容が伝わりづらいかもしれません。
画面の小さい端末を使うユーザは相対的に表示できる情報が減って本質が伝わりづらくなるかもしれません。
外出先で触るユーザにとっては、コントラストの低い配色は見分けづらく、情報が伝わりづらいかもしれません。
視力の低いユーザは小さな文字が見えづらかったり、特定の色の違いが分からないかもしれません。
注意欠陥障がいを持つユーザは、動き続けるUIによって情報を得ることが難しいかもしれません。
こうしたユーザによって異なる障壁は、先天的なものもありますし、後天的であったり環境による一時的なものもあります。

こうしたさまざまな種類・強度の障壁がある中で、できるだけ沢山のユーザへサービスの本質を届けよう、という試みがアクセシビリティの改善だと私は考えています。

Flutterのアクセシビリティに関する発表を見ると、世界には何らかの障がいを持つユーザが10億人以上(全人口の15%)いると説明されています。身体的なものや認知的なものもありますが、視覚に限ったとしても全盲、弱視、色弱などその種類はさまざまで、発表されている Victor Tsaran氏自身も障がいの当事者として、Material Designのアクセシビリティ対応を担当されているようです。
https://www.youtube.com/watch?v=bWbBgbmAdQs

では、Flutterではこのアクセシビリティに対してどのように向き合っているのでしょうか。

Flutterのアクセシビリティ対応

Flutterでは、以下の観点でアクセシビリティ対応することを強く推奨しています:

  1. フォントサイズ
  2. スクリーンリーダー対応
  3. コントラスト比

https://docs.flutter.dev/development/accessibility-and-localization/accessibility

Semanticsはこの中でも、スクリーンリーダー対応に対応する概念です。

スクリーンリーダーとは支援技術の一つで、画面上の要素を音声として読み上げたり、操作することのできるソフトウェアです。
メジャーなプラットフォーム(iOS, Android, Mac, Windows, Linux, ...)ではオプションを有効にしたり、追加のソフトウェアをインストールすることで機能を有効にすることができます。

Flutterは「first-class framework support for accessibility」と言っています。
フレームワークが提供する標準ウィジェットは、深く考えずとも適切なSemanticsが振られており、フレームワークの機能により適切に支援技術に送られます。(一部、発展途上な部分もあり正常に読み上げられないケースもありますが、改善に向かっています)
ですが、例えば開発者が標準ウィジェットのsemanticsLabelの指定をしなかったり、標準ウィジェットを本来とは違う使い方をする、もしくは独自UIを作成する場合、Semanticsに対する理解やデバッグの知識がないとアプリのアクセシビリティが低下してしまいます。

そうならないためにも、FlutterのSemanticsについて学んでいきましょう。

Semanticsの概念

FlutterのSemanticsの主役は、記事の冒頭に登場したSemanticsウィジェットです。

これを正しく使うことで、支援技術へ適切な情報を送信することができます。

SemanticsウィジェットとSemanticsNode

Semanticsを理解するときには、「Semanticsウィジェット」と「SemanticsNode」を分けて理解する必要があります。

Flutterフレームワーク内部ではいたる所で木構造が使用されます。Widget Tree, Element Tree, RenderObject Tree, Layer Treeなどが良く聞くものですね。(参考: Flutterレンダリングパイプライン入門, The Mahogany Staircase - Flutter's Layered Design

もう一つ大切な木構造が、SemanticsTreeです。
そして、このSemanticsTreeを構成するのが、SemanticsNodeです。

黒い丸で表現されたSemanticsNodeが木構造を成し、SemanticsTreeを構成している様子

この黒い丸のSemanticsNodeを生成するのが、Semanticsウィジェットです。

SemanticsウィジェットがRenderSemanticsAnnotationのRenderObjectを生成し、RenderSemanticsAnnotationがSemanticsNodeを生成する図

Semanticsウィジェットを使用すると、RenderObjectとしてRenderSemanticsAnnotationsがRenderObject Treeに挿入されます。そして、このRenserSemanticsAnnotationsがSemanticsNodeを生成し、SemanticsTreeを構築します。
(RenderObjectを自作すれば、直接SemanticsNodeを生成することも、もちろん可能です)

1点だけ注意が必要で、Semanticsウィジェットを使ったからといって、必ずSemanticsNodeが生成されるわけではありません。 SemanticsNodeは基本的に、複数のSemanticsウィジェットの情報を集約しています。

この集約をコントロールするのが、Semantics.containerというパラメータです。

Semantics.container: trueとfalseの違い

Semanticsウィジェットには container という大事なプロパティがあります。これを意識しないと、意図通りの読み上げになりません。

Semantics(
  container: true,
  ...
)

このcontainerというフラグは、SemanticsBoundaryというものを生成するかどうか、に対応しています。
SemanticsBoundaryは、「子供のSemanticsを集約する境界線」を表します。
SemanticsBoundaryが生成されるとき対応する新しいSemanticsNodeが生成され、子供のSemanticsはこの生成されたSemanticsNodeに集約されます。

少し説明がややこしくなりましたが、まとめるとこうです:
Semantics.container = true: 新しいSemanticsNodeを生成する
Semantics.container = false: SemanticsNodeを生成せず、親のSemanticsNodeにSemantics情報を合成する

Flutterでは、一つのUI部品は大量のWidgetから構成されていると思います。
主な使い方として、UI部品の一番上でSemantics.container = trueを指定し、内部で追加のSemantics情報を付け加えたいときはSemantics.container = falseを指定する、という場合が多いです。

Semantics.contaier = trueにするかどうかは、それ以下を独立したUI部品と定義するかどうか、と言えそうです。

SemanticsBoundaryを境に、container=trueとなっているSemanticsにcontainer=falseとなっているSemanticsが合成され、SemanticsNodeが構成される様子を表した図

MergeSemantics

MergeSemanticsウィジェットは、Semantics.container = trueとして独立したUIと定義されたSemanticsNode達を、一つのSemanticsNodeとして強制的に合成したい場合に使用します。
内部的には、SemanticsBoundaryによって新しいSemanticsNodeを生成した上で、isMergingSemanticsOfDescendantsというフラグをtrueにすることで、子供のSemanticsNodeをマージしています。

例えば、Sliderは一つの独立したUIであり、内部的にSemanticsNodeを作っています。
以下のようなUIを考えたとき、「温度設定」というラベルと、SliderのSemanticsが個別に独立してしまい、意味的には一つのUIですが、Semantics上は入れ子になった別々のUIとして認識されてしまいます。

// 一つのUIとして定義したい…
Semantics(
  container: true,
  child: Row(
    children: [
      const Text('温度設定:'),
      Slider(
        value: 25.0,
        min: 0,
        max: 100.0,
        onChanged: (val) {},
      ),
    ],
  ),
)

温度設定というラベルとスライダーが表示されている様子

FlutterはDebugモードで実行し、Sキーを押下することで現在のSemanticsTreeをダンプすることができるのですが、上記コードは以下のようなSemanticsTreeになっています。

SemanticsNode#19
│ Rect.fromLTRB(0.0, 0.0, 1280.0, 720.0)
│ label: "温度設定:"
│ textDirection: ltr
│
└─SemanticsNode#20
    Rect.fromLTRB(105.0, 0.0, 297.0, 720.0)
    actions: decrease, increase
    flags: hasEnabledState, isEnabled, isFocusable, isSlider
    value: "25%"
    increasedValue: "30%"
    decreasedValue: "20%"
    textDirection: ltr

not collectedと言われる場合は、TalkBackやVoiceOverをオンにしてからSキーを押してください)

また、MaterialAppshowSemanticsDebuggertrueにすることで視覚的にSemanticsNodeを確認することができます。(実際にTalkBackなどを有効にしてみるのも良いです)
温度設定のSemanticsNodeとSliderのSemanticsNodeが入れ子になっている様子

この二つの独立したSemanticsNodeを関連づけるために、MergeSemanticsを使うことができます。

MergeSemantics(
  child: Row(
    children: [
      const Text('温度設定:'),
      Slider(
        value: 25.0,
        min: 0,
        max: 100.0,
        onChanged: (val) {},
      ),
    ],
  ),
)

この状態でSemanticsTreeを確認してみると、以下のようにmerged upと表示され、親のSemanticsNodeにマージが行われたことが分かります。

SemanticsNode#17
│ merge boundary ⛔️
│ Rect.fromLTRB(0.0, 0.0, 1280.0, 720.0)
│ label: "温度設定:"
│ textDirection: ltr
│
└─SemanticsNode#18
    merged up ⬆️
    Rect.fromLTRB(105.0, 0.0, 297.0, 720.0)
    actions: decrease, increase
    flags: hasEnabledState, isEnabled, isFocusable, isSlider
    value: "25%"
    increasedValue: "30%"
    decreasedValue: "20%"
    textDirection: ltr

また、showSemanticsDebuggerの様子を確認すると、SliderとテキストラベルのSemanticsが一つのSemanticsNodeにマージされ、一つの独立したUIとして表示されています。また、Semantics情報もマージされ、スライダーにラベルが関連付けられることで、何の値を設定できるスライダーなのか分かりやすくなっています。

温度設定とスライダーのSemanticsNodeが一つになっている様子

この他に、ImageウィジェットなどもsemanticLabelを設定した場合は独立したSemanticNodeを生成します。画像と他のUIを一つのUIとして表示したいときは、MergeSemanticsの出番と言えるでしょう。

MergeSemanticsによって、子供のSemanticsNodeが合成される様子と、MergeSemanticsのSemanticsBoundaryによってSemanticsが従来通り合成される様子を表したSemanticsTreeの図

ExcludeSemantics

不要なSemanticsを除外することも適切なSemantics情報を管理するために大切な要素です。

例えば、実装上の都合で置かれているGestureDetectorウィジェットや、背景画像として使用しているImageウィジェットなど、本質的ではないがSemanticsとして存在してしまっているものもアプリ内にはあると思います。

このように、サービスの本質を伝達するうえで不要な情報であったり、実装上の都合で使われているが本来は不要なウィジェットのSemanticsを除外するために使用できるのが、ExcludeSemanticsウィジェットです。

このウィジェットで包んだ子供のSemanticsはSemanticsTreeから除外されるようになります。
また、Semantics.excludeSemanticsや、Image.excludeFromSemanticsなども同じくSemantics情報を除外するために使用できます。

Web界隈の例ですが、画像に指定する代替テキスト(FlutterではImageウィジェットのsemanticsLabel)をどのように付けるべきか、という以下のような決定チャートがあります。

Readableな夜 代替テキスト決定ツリー(下記URLの内容)のスクリーンショット
https://sawada-std-design.com/works/readable-na/readable-na-alt-decision-tree-20181105.pdf

決定チャートの中では、「隣接するテキストと重複」しているケースや「装飾画像」などの場合は代替テキストを空にすることが推奨されています。

Flutterで対応するとすれば、「隣接するテキストと重複」しているケースや「装飾画像」はImage.excludeFromSemanticsを指定していくのが正しい対応となります。
そして、もしexcludeしない画像なのであれば、上記決定チャートを参考に適切なImage.semanticLabelを指定する必要があります。
(何も指定しないデフォルト状態だと「画像」とだけ読み上げられ、どのような画像が表示されているのかが支援技術によって読み取ることができなくなります。世に出ているFlutterアプリはデフォルト状態のものが多いと感じます…)

ExcludeSemanticsによって、SemanticsTreeの一部のSemanticsNodeがTreeから除外される様子を表した木構造の図

その他にも

ここでは大事な要素である「Semanticsの概念」や「Merge/ExcludeSemantics」についてのみ説明しましたが、Flutterにはその他にも様々なSemantics要素が存在し、適切に使い分けていくことが大切です。
(ページ直下のウィジェットに作用しているexplicitChildNodesは、子供をSemanticsNodeとして独立させる効果があります。その他にも、ルートの名前情報を通知するscopesRoute/namesRoute、エラーメッセージや通知、値の変化をアナウンスするliveRegionなど様々な機能がSemanticsには存在します)

「どのように使い分ければ良いの?」という疑問については、基本的にはFlutterの似たような公式Widgetがどのように対応しているかを確認し、同じ対応をすることがベストプラクティスになります。
公式のGalleryアプリも参考になるでしょう。

また、Web向けのドキュメントではありますが例えばWCAG 2.0の内容もアクセシブルなアプリを作るために参考になると思います。

そして最後に、実際にTalkBackやVoiceOverで触ってみることがとても大切です。きちんと操作ができるのか、意味の分からないSemanticNodeがないか、フォーカス順序が適切か、プラットフォーム間で操作性に違いがないか、などを実際に触って確かめてみることをオススメします。

最後に

今回はSemanticsウィジェットを正しく使い、アプリをアクセシブルにする方法について説明しました。また、特別章を設けてはいませんが、どのようにSemanticsのデバッグを行うのかも説明しました。

ぜひ皆さんもSemanticsを理解し、できるだけ多くの人に届けられるアクセシブルなアプリをFlutterで作っていきましょう。

Discussion

UrasanUrasan

素晴らしい記事だと思います!参考になります、ありがとうございます。
私は教育学を専門としているのですが、一部「こうしたほうがいいのでは」という提案をさせて頂ければ幸いです。
①障碍者権利条約ではなく障害者権利条約です。

②障碍者への「考慮」ではなく、障がい者への「合理的配慮」と記す方が良いと思います。ニュアンスがかなり違いますし、私の周囲では考慮という言葉を使う事はご法度な程です。またこちらの界隈では合理的配慮という言葉はよく使われているのでこの記事の検索率も上がるかと思います。

(内容としては、障碍者の自立や完全な社会参加のために、公衆向けの施設やサービス、情報通信機器などに障碍者への考慮を行い、また関係者に対して講習を提供するような措置を各国に求めるものです)

③実は、エンジニア向けのアクセシビリティの話として「みんなの公共サイト運用ガイドライン」という物があります。こちらにはアクセシビリティの話は勿論として、どのような設計が良いのか、またこのガイドラインJIS規格と試験も2020年に行われています。以下、参考URL
https://www.mitsue.co.jp/knowledge/blog/a11y/202101/12_0928.html

④意図があれば良いのですが、「障がい者」と記述する場合に、障碍者や障害者と書くことは、お勧めできません。意図的に障碍者と書いている場合はその意図をくみ取れなくて申し訳ないのですが、個人的には「障がい者」と書く方が、せっかくSemanticsやそれを考慮した記事を書いているのに、「この人はわかっていないんだ」と捉えられるかもしれません。

以上です、素晴らしい記事をありがとうございます。

mjhdmjhd

ご指摘ありがとうございます。以下、反映しました:
①: 修正しました
②: ご指摘ありがとうございます、勉強になりました。誤った記述を削除しました
③: 情報提供ありがとうございます。参考にさせていただきます
④: 事前に使い分けを調べたのですが、誤っていたようです。「障がい」と修正しました