はじめに
以前執筆した「【Flutter】Semanticsで視覚障害者も使えるアプリを作ろう」では、Semantics ウィジェットの基礎を解説しました。今回はE2Eテスト自動化ツール MagicPod の導入にあたり、Semantics を深く動作確認する機会がありましたので、Flutterのセマンティクス関連のウィジェットやデバッグ方法についてまとめました。
Semanticsの基本
Semantics ツリーとは?
Flutter は通常のウィジェットツリー/レンダーツリーに加えて、Semantics ツリー を構築します。
各 UI 要素は SemanticsNode
としてメタ情報(ラベル/ヒント/値/フラグ/アクションなど)を持ち、これが OS のアクセシビリティ API(Android の AccessibilityNodeInfo
/iOS の UIAccessibilityElement
)に橋渡しされます。
デフォルトで付与される Semantics
- Text
- 自動的に
isText
フラグ付きのノードになり、文字列を読み上げ。 semanticsLabel
で読み上げ文字列を上書き可能。
- 自動的に
- Button や IconButton
button: true
、onTap
アクション付きのノード。
- Icon
- デフォルトでは
excludeFromSemantics: true
。 semanticLabel
を与えるとisImage
ノードとして読み上げ対象に。
- デフォルトでは
- TextField
isTextField
/isEditable
フラグ付き、labelText
/hintText
/value
を自動的に設定。
基本プロパティ
label
ユーザーに伝えたい簡潔な説明(例:「設定ボタン」)。hint
操作時に追加で読み上げるガイダンス(例:「ダブルタップで開く」)。value
現在の状態や入力値(例:「ボリューム 50%」)。- Boolean フラグ群
button
/link
/checked
/selected
など、要素の役割や状態を示す。 onTap
/onLongPress
など
スクリーンリーダー経由のアクション時に呼ばれるコールバック。
Semanticsの実装
Semantics境界の制御:container: true
と container: 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 をブロックし、下の操作が効かなくなります
- 改善策:
builder
でIgnorePointer(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 / フラグ / ラベル / アクション を階層表示
- 子孫関係や
container
/merge
の効果をテキストで追える - 内部で OS のアクセシビリティ API とどのようにマッピングされているか把握可能
標準WidgetにおけるSemantics
Flutter の標準ウィジェットは多くの場合、自動的に適切な Semantics 情報を提供してくれるよう設計されています。とはいえ、その挙動を正しく理解しておくことで、不要な重複を避けたり、欠落を補ったりできます。ここでは代表的なウィジェットについて見ていきましょう。
Text
の Semantics
-
自動的に読み上げる
Text('こんにちは')
を配置すると、Semantics ツリー上にはSemanticsNode: label="こんにちは", flag=isText
として登録されます。スクリーンリーダーはそのまま文字列を読み上げます。
-
semanticsLabel
で上書き可能Text( '1,234', semanticsLabel: '千二百三十四', )
のようにすると、画面には「1,234」と表示しつつも、アクセシビリティ上は「千二百三十四」と読み上げさせられます。
-
注意点
- 複数行のテキストも一塊として扱われる。改行は適宜スペースに置き換えられ読み上げられる。
- テキストだけでは操作アクションを持たない。タップを感知させたい場合は
GestureDetector
+Semantics(onTap: …)
が必要。
Icon
の Semantics
-
デフォルトでは除外
Icon(Icons.home)
は、excludeFromSemantics: true
がデフォルトなので、Semantics ツリーに一切現れません。 -
semanticLabel
で追加Icon( Icons.arrow_back_ios, semanticLabel: '戻る', )
とすると、
SemanticsNode: label="戻る", flag=isImage
が作られ、読み上げ対象となります。
-
よくあるユースケース
- 装飾的なアイコンはデフォルトで除外し、必要なものだけラベルを付与する
IconButton
やBackButtonIcon
のように 操作要素として組み込まれるアイコン では、親側が適切に Semantics を提供している。もしくはアイコンでsemanticLabel
を設定する。
TextField
の Semantics
-
自動的にテキストフィールドとして扱う
TextField(decoration: InputDecoration(...))
は、Semantics ツリー上でSemanticsNode: flag=isTextField, label=labelText or hintText, value=現在の入力文字列, actions=[tap, increase, decrease, …]
のように登録され、ユーザーは「メールアドレス、編集可能、ヒント: 例: user@example.com」などと案内されます。
-
InputDecoration.label
とhintText
の違いlabelText
: 常にラベルとして Semantics.label に含まれるhintText
: 入力値が空のときのみ Semantics.hint に含まれる
-
カスタムレイアウト時の対策
見た目を自由にレイアウトする場合は、MergeSemantics
でラベルと入力欄をまとめたり、必要があればSemantics(label: ..., textField: true)
で明示的に設定します。
DropdownButtonでの Semantics
複合的なウィジェット(DropdownButton
や CheckboxListTile
、IconButton
など)は内部で複数の Semantics ノードを組み合わせたり、適宜マージ・除外を行っています。
DropdownButton
では、以下のように親ウィジェットを Semantics
で包みこんで、DropdownButton
にはSemanticsを設定しつつ、DropdownMenuItem
はText
の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: true
と MergeSemantics
の違いは?
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'),
);
}),
],
),
),
),
),
);
}
}