【Flutter】Semantics関連を詳しく

はじめに

以前執筆した「【Flutter】Semanticsで視覚障害者も使えるアプリを作ろう」では、Semantics ウィジェットの基礎を解説しました。今回はE2Eテスト自動化ツール MagicPod の導入にあたり、Semantics を深く動作確認する機会がありましたので、Flutterのセマンティクス関連のウィジェットやデバッグ方法についてまとめました。

Semanticsの基本

【Flutter】Semanticsで視覚障害者も使えるアプリを作ろう

Semantics ツリーとは?

Flutter は通常のウィジェットツリー/レンダーツリーに加えて、Semantics ツリー を構築します。
各 UI 要素は SemanticsNode としてメタ情報(ラベル/ヒント/値/フラグ/アクションなど)を持ち、これが OS のアクセシビリティ API(Android の AccessibilityNodeInfo/iOS の UIAccessibilityElement)に橋渡しされます。

デフォルトで付与される Semantics

  • Text
    • 自動的に isText フラグ付きのノードになり、文字列を読み上げ。
    • semanticsLabel で読み上げ文字列を上書き可能。
  • Button や IconButton
    • button: trueonTap アクション付きのノード。
  • Icon
    • デフォルトでは excludeFromSemantics: true
    • semanticLabel を与えると isImage ノードとして読み上げ対象に。
  • TextField
    • isTextFieldisEditable フラグ付き、labelTexthintTextvalue を自動的に設定。

基本プロパティ

  • label
    ユーザーに伝えたい簡潔な説明(例:「設定ボタン」)。
  • hint
    操作時に追加で読み上げるガイダンス(例:「ダブルタップで開く」)。
  • value
    現在の状態や入力値(例:「ボリューム 50%」)。
  • Boolean フラグ群
    buttonlinkcheckedselected など、要素の役割や状態を示す。
  • onTaponLongPress など
    スクリーンリーダー経由のアクション時に呼ばれるコールバック。

Semanticsの実装

Semantics境界の制御:container: truecontainer: false

この例では index が偶数/奇数で container を切り替えています。

Semantics(
  container: isContainer,  // index % 2 == 0 の時だけ true
  label: 'labelInSemantics: $index',
  child: ListTile(
    title: Semantics(
      container: isContainer,
      child: Text('text-$index', semanticsLabel: ...),
    ),
  ),
)
  • container: false(デフォルト)

    • 親 Semantics ノードへのマージを許可
    • 小要素ごとに分割された SemanticsNode は「論理的にひとまとまり」でない限り統合されやすい
  • container: true

    • ここで必ず新しい SemanticsNode を開始
    • 「この範囲は他から切り離したい」という境界を明示
    • 子孫のノードはそのまま残るが、親との無秩序なマージを防止

使いどころ:

  • カードやグループごとに「一塊のコンテナー」として独立したフォーカス領域を維持したい
  • 内部の小要素(ボタンやテキスト入力)は別ノードとして扱いたい

冗長情報の排除:ExcludeSemantics

メニューアイコンの Semantics を除外する例です。

trailing: ExcludeSemantics(
  excluding: isMenuExclude, // index が 2,3 のとき true
  child: IconButton(
    icon: Icon(Icons.menu, semanticLabel: 'menu-$index'),
    onPressed: (){}
  ),
),
  • excluding: true の場合、子孫の全 Semantics 情報を ツリーから除外
  • 装飾や重複ラベル、操作不要なアイコンを読み上げ対象から外し、ノイズを減らす

活用パターン:

  • スクリーンリーダーには伝えなくて良い装飾的アイコン
  • 視覚的に存在するが、音声案内上は冗長な要素の非表示化

複数要素の一体化:MergeSemantics

チェックボックスとラベルをひとまとまりにまとめるのは定石です。サンプルでは次のように実装しています。

MergeSemantics(
  child: Row(
    children: [
      Checkbox(
        value: _checkboxValue,
        onChanged: (v) => setState(() => _checkboxValue = v!),
      ),
      const SizedBox(width: 8),
      Text('MergeSemanticsの例'),
    ],
  ),
),
  • 複数の SemanticsNode を 1 つに統合
  • 読み上げは「MergeSemanticsの例、チェックあり・なし、チェックボックス」のように一度で完了
  • ノード数削減による性能面のメリットも(大量リストで特に有効)

ポイント:

  • 必要な論理単位 のみ統合し、別操作要素は誤ってまとめない
  • 実機での TalkBack/VoiceOver で「期待どおり一気に読み上げられるか」確認

Semanticsのデバッグ

デバッグオーバーレイ:showSemanticsDebugger の切り替え

FABアイコンで SemanticsDebugger の表示をトグルしています。

MaterialApp(
  showSemanticsDebugger: _showSemanticsDebugger,
  ...
),
floatingActionButton: FloatingActionButton(
  onPressed: () => setState(() => _showSemanticsDebugger = !_showSemanticsDebugger),
  child: Icon(_showSemanticsDebugger ? Icons.check : Icons.add),
),
  • showSemanticsDebugger: true で半透明の枠とラベルをオーバーレイ表示
  • 注意: デフォルトだとこのオーバーレイが HitTest をブロックし、下の操作が効かなくなります
  • 改善策: builderIgnorePointer(ignoring: true) を使い、操作を透過させる方法が推奨されます

ツリー構造の可視化:debugDumpSemanticsTree()

デバッグボタンを押すとコンソールに Semantics ツリーを出力します。

ElevatedButton(
  onPressed: () {
    debugDumpSemanticsTree();
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('Semantics tree dumped to console')),
    );
  },
  child: Text('Dump Semantics Tree'),
),
  • ルートノードから各子ノードまで、Rect / フラグ / ラベル / アクション を階層表示
  • 子孫関係や containermerge の効果をテキストで追える
  • 内部で OS のアクセシビリティ API とどのようにマッピングされているか把握可能

標準WidgetにおけるSemantics

Flutter の標準ウィジェットは多くの場合、自動的に適切な Semantics 情報を提供してくれるよう設計されています。とはいえ、その挙動を正しく理解しておくことで、不要な重複を避けたり、欠落を補ったりできます。ここでは代表的なウィジェットについて見ていきましょう。

Text の Semantics

  • 自動的に読み上げる
    Text('こんにちは') を配置すると、Semantics ツリー上には

    SemanticsNode: label="こんにちは", flag=isText
    

    として登録されます。スクリーンリーダーはそのまま文字列を読み上げます。

  • semanticsLabel で上書き可能

    Text(
      '1,234',
      semanticsLabel: '千二百三十四',
    )
    

    のようにすると、画面には「1,234」と表示しつつも、アクセシビリティ上は「千二百三十四」と読み上げさせられます。

  • 注意点

    • 複数行のテキストも一塊として扱われる。改行は適宜スペースに置き換えられ読み上げられる。
    • テキストだけでは操作アクションを持たない。タップを感知させたい場合は GestureDetectorSemantics(onTap: …) が必要。

Icon の Semantics

  • デフォルトでは除外
    Icon(Icons.home) は、excludeFromSemantics: true がデフォルトなので、Semantics ツリーに一切現れません。

  • semanticLabel で追加

    Icon(
      Icons.arrow_back_ios,
      semanticLabel: '戻る',
    )
    

    とすると、

    SemanticsNode: label="戻る", flag=isImage
    

    が作られ、読み上げ対象となります。

  • よくあるユースケース

    • 装飾的なアイコンはデフォルトで除外し、必要なものだけラベルを付与する
    • IconButtonBackButtonIcon のように 操作要素として組み込まれるアイコン では、親側が適切に Semantics を提供している。もしくはアイコンで semanticLabel を設定する。

TextField の Semantics

  • 自動的にテキストフィールドとして扱う
    TextField(decoration: InputDecoration(...)) は、Semantics ツリー上で

    SemanticsNode: 
      flag=isTextField, 
      label=labelText or hintText, 
      value=現在の入力文字列, 
      actions=[tap, increase, decrease, …]
    

    のように登録され、ユーザーは「メールアドレス、編集可能、ヒント: 例: user@example.com」などと案内されます。

  • InputDecoration.labelhintText の違い

    • labelText: 常にラベルとして Semantics.label に含まれる
    • hintText: 入力値が空のときのみ Semantics.hint に含まれる
  • カスタムレイアウト時の対策
    見た目を自由にレイアウトする場合は、MergeSemantics でラベルと入力欄をまとめたり、必要があれば Semantics(label: ..., textField: true) で明示的に設定します。

複合的なウィジェット(DropdownButtonCheckboxListTileIconButton など)は内部で複数の Semantics ノードを組み合わせたり、適宜マージ・除外を行っています。
DropdownButtonでは、以下のように親ウィジェットを Semantics で包みこんで、DropdownButtonにはSemanticsを設定しつつ、DropdownMenuItemTextのSemanticsで情報を与えてます。

Semantics(
  label: 'dropdown-label',
  onTapHint: '数字を入力',
  child: DropdownButton<String>(
    value: _selectedDropdownMenu,
    items: _dropdownMenu
        .map((e) => DropdownMenuItem<String>(
            value: e, child: Text(e)))
        .toList(),
    onChanged: (e) =>
        setState(() => _selectedDropdownMenu = e!),
  ),
),
  • ユーザー体験

    • 「dropdown-label、one、ボタンド、 数字を入力にはダブルタップ」という案内が得られる
    • 開いて押すと「two、ボタン」と連続案内

Q&A

Q1. container: trueMergeSemantics の違いは?

  • container: true
    • Semantics ツリー上に「ここで必ず新しいノード境界を作る」
    • 親ノードとのマージを防ぎ、子孫ノードは個別に残す
  • MergeSemantics
    • 子孫すべての情報を 1 つのノードにまとめる
    • チェックボックス+ラベルなど「論理的に一つの操作単位」をまとめたい場合に使用

Q2. ExcludeSemantics はどんなときに使う?

  • 視覚的には存在するが、スクリーンリーダーには不要 な装飾アイコン
  • デフォルトで付与された重複ラベルを排除し、情報の冗長化を防ぐ
  • 例:Chip ウィジェットのアバター部分や、Divider/スペーサーなど

まとめ

本記事では Flutter の Semantics を中心に、Semantics ウィジェットを使った要素の意味付けや container/MergeSemantics/ExcludeSemantics によるノード境界・統合・除外、semanticsLabel による読み上げカスタマイズ、Semantics ツリーの可視化・ダンプ手法を詳説しました。

参考

ソース(main.dartにコピペして動作確認用)

Flutter: 3.29.2 で確認

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; // for debugDumpSemanticsTree()

void main() {
  runApp(SemanticsDemoApp());
}

class SemanticsDemoApp extends StatefulWidget {
  @override
  _SemanticsDemoAppState createState() => _SemanticsDemoAppState();
}

class _SemanticsDemoAppState extends State<SemanticsDemoApp> {
  bool _showSemanticsDebugger = false;
  bool _checkboxValue = false;
  final _dropdownMenu = <String>['one', 'two', 'three'];
  late String _selectedDropdownMenu = _dropdownMenu.first;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Semantics Demo',

      // 切り替え可能なSemanticsDebugger
      showSemanticsDebugger: _showSemanticsDebugger,
      home: Scaffold(
        appBar: AppBar(
          title: Text('Flutter Semantics Demo'),
          actions: [
            IconButton(
                onPressed: () {},
                icon: Icon(
                  Icons.menu,
                  semanticLabel: 'menuInAppBar',
                ))
          ],
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () => setState(
              () => _showSemanticsDebugger = (!_showSemanticsDebugger)),
          child: Icon(
            _showSemanticsDebugger ? Icons.check : Icons.add,
            semanticLabel: 'change-semantics',
          ),
        ),
        body: Padding(
          padding: const EdgeInsets.all(16),
          child: SingleChildScrollView(
            child: Column(
              children: [
                SizedBox(
                  height: 64 * 6,
                  child: ListView.builder(
                      itemCount: 6,
                      itemBuilder: (context, index) {
                        bool isContainer = index % 2 == 0;
                        bool isMenuExclude = 1 < index && index < 4;
                        bool useSemanticsLabel = index < 4;
                        bool useTextLabel = index < 2;

                        return Semantics(
                          label: useSemanticsLabel
                              ? 'labelInSemantics: $index'
                              : null,
                          child: ListTile(
                            title: Semantics(
                              container: isContainer,
                              child: Text(
                                'text-$index',
                                semanticsLabel:
                                    useTextLabel ? 'labelInText: $index' : null,
                              ),
                            ),
                            trailing: ExcludeSemantics(
                              excluding: isMenuExclude,
                              child: IconButton(
                                  onPressed: () {},
                                  icon: Icon(
                                    Icons.menu,
                                    semanticLabel: 'menu-$index',
                                  )),
                            ),
                          ),
                        );
                      }),
                ),

                // MergeSemantics の例
                MergeSemantics(
                  child: Row(
                    children: [
                      Checkbox(
                        value: _checkboxValue,
                        onChanged: (v) => setState(() => _checkboxValue = v!),
                      ),
                      const SizedBox(width: 8),
                      Text('MergeSemanticsの例'),
                    ],
                  ),
                ),
                CheckboxListTile(
                  title: Text('CheckboxListTileの例'),
                  value: _checkboxValue,
                  onChanged: (v) => setState(() => _checkboxValue = v!),
                ),

                Divider(),
                SizedBox(
                    width: 256,
                    child: Icon(Icons.arrow_back_ios,
                        semanticLabel: 'back-button1')),
                Semantics(
                    label: 'back-button2',
                    child: SizedBox(
                        width: 256,
                        child: Icon(Icons.arrow_back_ios_new_rounded))),

                Divider(),
                SizedBox(width: 256, child: BackButtonIcon()),
                Semantics(
                    label: 'back-button4',
                    child: InkWell(
                        onTap: () => print('tapped'),
                        child: SizedBox(width: 256, child: BackButtonIcon()))),
                Semantics(
                    label: 'back-button5',
                    child: InkWell(
                        onTap: () => print('tapped'),
                        child: SizedBox(
                            width: 256,
                            child: ExcludeSemantics(child: BackButtonIcon())))),

                Divider(),
                Semantics(
                  label: 'dropdown-label',
                  onTapHint: '数字を入力',
                  child: DropdownButton<String>(
                      value: _selectedDropdownMenu,
                      items: _dropdownMenu
                          .map((e) => DropdownMenuItem<String>(
                              value: e, child: Text(e)))
                          .toList(),
                      onChanged: (e) =>
                          setState(() => _selectedDropdownMenu = e!)),
                ),
                TextField(
                  decoration: InputDecoration(label: Text('text-label')),
                ),

                // debugDumpSemanticsTree() を呼ぶボタン
                Builder(builder: (context) {
                  return ElevatedButton(
                    onPressed: () {
                      debugDumpSemanticsTree();
                      ScaffoldMessenger.of(context).showSnackBar(
                        SnackBar(
                            content: Text('Semantics tree dumped to console')),
                      );
                    },
                    child: Text('Dump Semantics Tree'),
                  );
                }),
              ],
            ),
          ),
        ),
      ),
    );
  }
}