綴Photo の勉強ノートに戻る
Flutter技術2026.06.19

アクセシビリティ対応を『マウスで説明が出るやつ』だと思っていた — 読み上げ機と、どこまでやるかの線引き

Flutterアクセシビリティ設計判断綴Photo

AIのコードレビューで指摘された箇所を直す前に「これ何の話だ?」で一回つまずいた、その整理メモです。

綴Photo は写真を選んで、そこに気持ちを綴るアプリ。AI のコードレビューで「写真や本棚、入力欄にアクセシビリティの情報が足りない」と指摘された。直そうとして、まず自分がアクセシビリティ対応を勘違いしていたことに気づいた。

最初は「マウスを写真の上に乗せたら、ふきだしで説明が出るやつ」だと思っていた。デスクトップ版だけの話だろう、とも。どっちも違った。

「マウスを乗せたら説明が出る」やつではなかった

アクセシビリティ対応というのは、この読み上げに「ここは押せるボタンで、写真を選ぶものだよ」と伝える情報を持たせること。画面の見た目は何も変わりません。ツールチップだと思っていたから「デスクトップでマウスを乗せたとき」を想像していたけど、本当は目に見えないし、むしろスマホの読み上げ利用者にいちばん効きます。

今は、読み上げ機に何も伝わっていなかった

Flutter では画面のボタンや画像など部品ひとつひとつを「ウィジェット」と呼びます。レゴのブロックを組むように、これを重ねて画面を作ります。

写真のサムネはこうなっていた。

dart
// タップは拾うが、読み上げ機には「ただの四角」にしか見えない
GestureDetector(
  onTap: onTap,
  child: photoThumbnail,
)

ElevatedButton のようなマテリアル部品は最初から「ボタン」のセマンティクスを持ちますが、GestureDetector のような素の部品は自分で意味づけしない限り無言になります。見た目を自作した押せる領域ほどこの穴が出やすいです。

GestureDetector はタップを拾うだけの透明な領域で、それ自体は「ボタン」とも「写真」とも名乗りません。タップを受け取る専用のウィジェットなので、「名前」も「ボタンか否か」も一切持っていないのです。スクリーンリーダーはウィジェットが持つセマンティクス情報(名前・役割)を読んで音声にするので、情報がなければ沈黙するしかありません。目で見れば写真が並んでいると分かるけど、音声だけで操作する人には、そこに何があるのか・押せるのかが伝わりません。本棚の本も同じだった。入力欄に至っては、見た目のヒント文(3秒ごとに入れ替わるやつ)が別レイヤーの飾りで描かれていて、読み上げ機には「ただのテキスト欄」としか届いていなかった。

名前と「ボタンだよ」を足す

見た目と読み上げは別の箱。地図はメモリで、DB には保存しない。画面(描画)写真のサムネ本棚の背表紙入力のヒント文(3秒で交代)ピクセル。地図には乗らない開いている間だけ描く意味の地図写真を選ぶ・ボタン○月の記憶を開く・ボタン綴る言葉の入力欄(固定)メモリ。保存しない。画面には出ないヒント文は乗らない読み上げ機(VoiceOver / TalkBack)が地図を音声にする。ラベルはソースの定数。地図はメモリ。アプリの DB には書かない。

やったことは、押せる要素を Semantics で包んで、名前と「ボタン」の役割を持たせる形にした。

dart
// 読み上げ機に「写真を選ぶ、ボタン」と伝わる。見た目は1ピクセルも変わらない
Semantics(
  button: true,      // 「押せる要素」という役割を意味の地図に登録(Callout の「ボタン」に対応)
  label: '写真を選ぶ', // 読み上げ機が読む「名前」(Callout の「名前を持たせる」に対応)
  child: GestureDetector(onTap: onTap, child: photoThumbnail), // 実際のタップ処理と見た目
)

綴Photo は実装を AI に手伝ってもらいながら進めているので、この Semantics の当て方も AI の提案を一度確かめてから適用した。

ただし、全部が「包むだけ」で済んだわけではなかった。気分を選ぶチップ(絵文字とラベルが縦に並んだ丸いボタン)は、そのまま包むと絵文字とラベルが別々の項目として2回読まれます。子の要素を読み上げ対象から外す指定でひとまとめにできるのですが、これを付けると子のタップ判定ごと落ちてしまいます。結局タップ処理をラッパー側にもう一度書くことになった。

dart
Semantics(
  button: true,
  selected: isSelected,     // 「選択済み」も読み上げに乗る
  label: label,
  excludeSemantics: true,   // 絵文字とラベルが2回読まれるのを止め、1つにまとめる
  onTap: onTap,             // ← 子ごと除外されるので、タップをここに付け直す
  child: InkWell(onTap: onTap, /* 見た目 */),
)
画面の並びと地図の項目数は別。地図はメモリで、保存しない。画面(描画)絵文字と「うれしい」が縦に並ぶ。InkWell の見た目は残る意味の地図・子を残す絵文字 / ラベルの2項目2回読まれる。メモリ・保存しない意味の地図・子を除外うれしい・ボタン・選択済み1項目。タップはここに付け直す。メモリまとめ読みは地図の項目を減らす操作である。画面の並びは触らない。

名前を足すだけで終わる箇所と、読み上げの聞こえ方まで整える箇所があって、後者は数行の追加判断が要ります。

どこまでやるか、を決めた

ここが一番考えたところ。レビューでは「キーボードだけで操作できるように、Tab で移動して Enter で押せるようにするのもいい」とも言われていた。でも入れると、本のホバー演出(マウスを乗せると少し拡大するやつ)と噛み合わせる作業が要りますし、デザインに手が入ります。

それで、ラベルを足すところまでで止めると決めた。理由は、綴Photo が「写真を見て、言葉を綴る」=視覚が前提の体験だから。スクリーンリーダーだけで完結させるのが主役のアプリではありません。費用対効果を見て、こう線を引いた。

  • 低コストで効く「名前とボタンの役割」は足す(読み上げ機が黙らなくなる)
  • デザインに食い込む「キーボード操作」は今はやらない

「最小限」と言っても、やらないことにしたわけじゃない。低コストで効く側だけ拾って、コストの重い側は優先度を下げる、という切り分けをした、という意味。

学びポイント

  • アクセシビリティ対応は、画面に出るツールチップでもデスクトップの話でもありません。目に見えない、読み上げ機向けの情報。むしろスマホで効きます
  • 「押せる透明な領域」は、目では分かっても音声利用者には沈黙になります。名前と役割を足すだけで、読み上げ機が反応するようになります
  • どこまでやるかは、アプリの性質で線を引いていいです。視覚前提のアプリなら、低コストで効くラベルまで。デザインに食い込む操作系は後回し、という切り分けは持っておけます

おわりに

直し自体は、たいていの箇所は要素を Semantics で包むだけで済み、コード量は少ない。でも手を動かす前に「これは何の話で、誰のための機能で、このアプリではどこまでやるべきか」を一回整理したのが、今回の本体だった気がする。指摘を上から順に全部やるより、性質を見て線を引くほうが、今回は効いた。実装は AI と進めていても、どこまでやるかの線引きは AI の提案を受けて自分で決める。そこは渡さずに持っておきたい部分だった。


この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。