Claude CodeでJetpack Composeのsemanticsとタッチ領域の不足を直す
Jetpack ComposeのアイコンボタンにcontentDescriptionがない、行が1要素にまとまらない、タップ領域が48dpに届かない。この3つをテストで落とし、Claude Codeに直させる手順です。
Jetpack Composeのアクセシビリティ不備は、画面を見ているだけでは分かりません。アイコンだけのボタンにラベルがなくても、見た目は正常に描画されます。TalkBackで読ませて初めて「ボタン」としか聞こえないことに気づく、という種類の不具合です。
この記事では、不備を「テストが落ちる形」に変えてから、Claude Codeに直させます。対象は、contentDescriptionの欠落、mergeDescendantsによる要素のまとまり、48dpのタッチ領域の3つです。
semanticsとは、画面の意味を支援技術に渡す仕組み
semanticsは、Composeで部品の意味と役割を表す情報です。アクセシビリティサービス、オートフィル、テストの3者が、この情報を読みます。たとえばカメラのアイコンは、見た目では画像にすぎませんが、semanticsでは「写真を撮る」という意味を持てます。
Materialと基盤のAPIは、役割に応じた既定のsemanticsを持っています。Switchなら、役割、状態の説明、トグル状態、クリック時の動作が入ります。TalkBackは、この情報から「オン、スイッチ、ダブルタップで切り替え」と読み上げます。
テストも同じ情報を使います。composeTestRule.onNode(...)でノードを探し、assertIsOff()で状態を確かめる操作は、すべてsemanticsツリーが対象です。ここが、この記事の手順の要になります。
semanticsを直せば、読み上げとテストの両方が変わります。逆にいえば、テストでsemanticsを検査すれば、TalkBackを毎回起動しなくても不備を拾えます。
直す対象は3つ — 症状と原因を対応させる
3つの不足は、症状がまったく違います。先に表で切り分けておくと、Claudeに渡す依頼文が具体的になります。
| 不足 | TalkBackで起きること | 主な原因 | 見るべきAPI |
|---|---|---|---|
| ラベルの欠落 | TalkBackで起きること「ボタン」とだけ読まれる | 主な原因IconのcontentDescriptionがnullか空 | 見るべきAPIcontentDescription |
| 要素のまとまり | TalkBackで起きること1行が複数回に分かれて読まれ、操作も分かれる | 主な原因行全体をまとめる指定がない | 見るべきAPImergeDescendants、clickable |
| タッチ領域 | TalkBackで起きること小さい要素を押しづらい | 主な原因サイズ指定が48dp未満 | 見るべきAPIsizeIn |
contentDescriptionは「画像に意味があるか」で決める
ImageやIconは、Androidフレームワークが中身を自動で理解できません。そのため、意味を持つ画像には文字列での説明を渡します。説明は、利用者に聞こえる文字列なので、ローカライズ済みの文字列リソースを使います。
IconButton(onClick = onClick) {
Icon(
imageVector = Icons.Filled.Share,
contentDescription = stringResource(R.string.label_share)
)
}一方、装飾だけの画像にはcontentDescription = nullを指定します。この指定は「操作も状態も持たない要素」という意思表示で、読み上げ対象から外れます。
ここがClaudeに直させるときの落とし穴です。「contentDescriptionが無い警告を消して」と頼むと、nullを入れて警告だけを消す直し方が成立してしまいます。意味のあるアイコンには文字列、装飾にはnullと、判断の基準を依頼文に書きます。
mergeDescendantsは「1つの操作」を1つのノードにする
Buttonの中には、アイコンと文字が入っています。それでもアクセシビリティ上は1つのノードです。Button内部で使われるclickableが、子孫のsemanticsを親にまとめる指定(mergeDescendants = true)を含んでいるためです。
clickableとtoggleableは、子をまとめる動作が既定です。ListItemも同様です。自作の行コンポーネントでこの修飾子を使っていない場合に、アイコンと文字が別々に読まれます。
Row(
modifier = Modifier.clickable(onClickLabel = "この記事を開く") {
openArticle()
}
) {
Icon(
painter = painterResource(R.drawable.ic_logo),
contentDescription = "開く"
)
Text("Composeのアクセシビリティ")
}onClickLabelを付けると、TalkBackの案内が「ダブルタップで有効にする」という汎用の文言から、「ダブルタップでこの記事を開く」に変わります。小さな指定ですが、操作の結果が分かるようになります。
行の中に複数の操作ボタンがある場合は、考え方が変わります。スワイプで消す操作や、行内の複数ボタンは、customActionsに移してから、元の子のsemanticsをclearAndSetSemantics { }で消す設計が示されています。この変更は影響が大きいので、Claudeに任せる場合も、単独のコミットに分けて確認します。
48dpは「見た目」ではなく「測れる数字」
押せる要素は、確実に操作できる大きさが必要です。Material Designの指針に沿うなら、最小は48dpです。Checkbox、RadioButton、Switch、Slider、Surfaceは、操作可能なときに限って、この最小サイズを内部で確保します。CheckboxのonCheckedChangeがnullだと、パディングは付きません。
もう一つ、見落としやすい点があります。48dp未満のclickableでも、Composeはタップできる範囲を要素の外側へ広げます。小さなBoxを押したとき、見た目の外側を触っても反応するのはこのためです。
ただし、この広がりは要素の「見た目」を大きくしません。見た目も含めて48dp以上にしたいなら、sizeIn(minWidth = 48.dp, minHeight = 48.dp)のように最小サイズを明示します。
アクセシビリティチェックをテストに組み込む
不足をテストで落とすために、まずAndroid側のチェック機能を有効にします。Compose 1.8.0以降では、Accessibility Test Frameworkによる自動チェックを、Composeのテストから呼び出せます。Accessibility ScannerやEspressoのチェックと同じ基盤です。
手順は3つです。
チェックを有効にする手順
- 1
依存関係を足す
ui-test-junit4-accessibilityをテスト用の依存関係に追加します。 - 2
ルールでチェックを有効にする
AndroidComposeTestRuleに対してenableAccessibilityChecks()を呼びます。 - 3
チェックを走らせる
performClickなどの操作をすると、チェックも走ります。操作を挟まない場合は、onRoot().tryPerformAccessibilityChecks()で明示的に実行します。
テストは次の形になります。ラベルが空の、クリックできるBoxを置き、チェックが失敗することを確認する例です。
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun noAccessibilityLabel() {
composeTestRule.setContent {
Box(
modifier = Modifier
.size(50.dp, 50.dp)
.background(color = Color.Gray)
.clickable { }
.semantics { contentDescription = "" }
)
}
composeTestRule.enableAccessibilityChecks()
composeTestRule.onRoot().tryPerformAccessibilityChecks()
}この種のテストは、サービスが読めるラベルが無い要素について、例外とメッセージで失敗します。メッセージが「何が悪いか」を文章で返すため、Claudeがそのまま修正の手がかりにできます。
自動チェックの対象には、ラベルのほかに、色のコントラスト、小さなタッチ領域、フォーカスの順序が挙げられています。警告まで落としたいなら、AccessibilityValidatorを作ってsetThrowExceptionForでWARNINGを指定し、enableAccessibilityChecksに渡します。
テストを動かすコマンドは、プロジェクトの構成に合わせます。計装テストとして置いた場合は、次のような形になります。
./gradlew connectedDebugAndroidTest \
--tests "*AccessibilityTest"自動チェックの外にある不足を、アサーションで補う
チェックが拾う範囲は、画面ごとの状況に左右されます。ラベル欠落のように確実に落ちるものもあれば、タッチ領域のように、見た目と実際の領域が一致しないものもあります。そこで、3つの不足を直接アサーションで書いたテストを、チェックと併用します。
@Test
fun shareButton_hasLabel_and_isLargeEnough() {
composeTestRule.setContent { ShareButton(onClick = {}) }
composeTestRule
.onNodeWithContentDescription("共有")
.assertHasClickAction()
.assertWidthIsAtLeast(48.dp)
.assertHeightIsAtLeast(48.dp)
}
@Test
fun articleRow_isSingleNode() {
composeTestRule.setContent { ArticleRow(title = "Composeの基礎") }
// 行の文字で探してクリックできる = 子が親にまとまっている
composeTestRule.onNodeWithText("Composeの基礎").performClick()
}1つ目は、ラベルで探せること、クリック可能なこと、幅と高さが48dp以上であることを一度に確かめます。ラベルで探すテストは、ラベルが無ければそもそも見つからずに落ちます。
2つ目は、まとまりの確認です。テストの検索は、既定でマージ後のツリーを使います。そのため、Buttonの中の「Like」という文字で探してクリックできます。同じ書き方で行がクリックできなければ、まとまっていない可能性が高いと分かります。
マージ前後のツリーを出力して原因を絞る
原因が分からないときは、ツリーを出力します。printToLogは、既定でマージ後のツリーを出します。マージ前を見たいときは、onRoot(useUnmergedTree = true)を使います。
composeTestRule.onRoot().printToLog("MERGED")
composeTestRule.onRoot(useUnmergedTree = true).printToLog("UNMERGED")アクセシビリティサービスは、マージ前のツリーを使い、mergeDescendantsの指定を見て自分で統合します。テストの既定とは見ている木が違うため、2つの出力を並べて比べると、まとまっていない場所が見えます。ログの出力をClaudeに貼れば、「この行は子が別ノードのまま」という状況を共有できます。
Claude Codeに渡す依頼文と、直し方の線引き
テストを書いたら、Claude Codeには「落ちているテストを通す」ことを目的に頼みます。一般的な作業手順の中でも、テストを実行して失敗を直すという流れが示されています。
ShareButtonとArticleRowのアクセシビリティテストが失敗しています。
./gradlew connectedDebugAndroidTest --tests "*AccessibilityTest" を実行し、
失敗メッセージを読んで直してください。
- 意味のあるアイコンには stringResource の contentDescription を入れる
- 装飾だけの画像は contentDescription = null のままにする
- タッチ領域は sizeIn(minWidth = 48.dp, minHeight = 48.dp) で確保する
- テストの期待値を弱めて通さない最後の1行が重要です。テストを書き換えて通す直し方を許すと、不備が残ったまま成功します。
Claude Codeのベストプラクティスにも、完了した証拠としてコマンドとその出力を求める考え方があります。「通りました」の一言ではなく、実行したコマンドと結果を出させます。依頼文にも「実行したコマンドと失敗メッセージの要約を最後に示す」と足しておくと、確認が楽になります。
直し方に迷う場面は、次のように整理できます。
| 場面 | 直し方の方向 | 避けたいこと |
|---|---|---|
| 意味のあるアイコンにラベルがない | 直し方の方向stringResourceでラベルを入れる | 避けたいこと固定の英語文字列を直書きする |
| 装飾の画像が読み上げられる | 直し方の方向contentDescription = nullにする | 避けたいこと無意味な説明文を入れる |
| 行が複数回に分かれて読まれる | 直し方の方向clickableかtoggleableでまとめる | 避けたいこと子に個別のclickableを重ねる |
| 小さなアイコンボタン | 直し方の方向MaterialのIconButtonを使うかsizeInで48dpにする | 避けたいこと見た目を保つために領域を削る |
| スワイプ専用の操作 | 直し方の方向customActionsに同じ操作を用意する | 避けたいことジェスチャだけで操作を完結させる |
CLAUDE.mdとフックで、毎回の検査を約束にする
同じ指示を毎回書くのは手間です。繰り返し使う方針は、CLAUDE.mdに置きます。CLAUDE.mdはセッション開始時に読み込まれ、ビルドコマンドやテスト手順を共有する場所です。Compose全般の書き方はClaude CodeでJetpack Compose開発を進める手順にまとめています。ここでは、アクセシビリティに関する数行だけを足します。
# アクセシビリティ
- 画面に手を入れたら ./gradlew connectedDebugAndroidTest --tests "*AccessibilityTest" を実行する
- 意味のあるIcon/Imageには stringResource の contentDescription、装飾なら null
- 押せる要素は 48dp 以上。Materialの部品を優先し、自作ならsizeInで確保する
- アクセシビリティのテストの期待値を緩めて通さないさらに確実にしたい場合は、フックを使えます。PostToolUseは、ツール呼び出しが成功したあとに動くイベントで、Edit|Writeのマッチャーでファイル編集の直後だけに絞れます。フォーマッタを走らせる例が、フックのガイドに載っています。アクセシビリティのテストは時間がかかるため、編集のたびに走らせるのは重く、完了前に一度走らせる構成のほうが現実的です。フックの設定方法はClaude Code Hooks完全ガイドで扱っています。
自動検査が見つけないもの
自動チェックが通っても、アクセシビリティが満たされたことにはなりません。公式のテストの説明でも、自動テストは手動確認と組み合わせて使うものとされています。TalkBackやSwitch Accessを実際に有効にして、期待どおり動くかを確かめる作業は残ります。
テストが通っても残りやすいのは、次の3点です。
- ラベルの文言が適切か。「ボタン」「画像」のように、あっても役に立たない文言は検査をすり抜けます
- 読み上げの順序が自然か。見た目の並びと、フォーカスの移動順は別物です
- 画面全体の流れが分かりやすいか。見出しの
heading()や、更新を知らせるliveRegionは、意図を持って付ける必要があります
文言の良し悪しは、Claudeに候補を挙げさせたうえで、人間が実機で聞いて決める作業です。Webの画面を対象にした同種の検査はClaude CodeでPlaywrightとaxeのアクセシビリティ検査をPRで落とす手順に書いています。仕組みは違いますが、「検査で落とす、直させる、人が確かめる」という分担は共通です。
まとめ
Composeのアクセシビリティは、見た目から離れて、semanticsツリーで検査するのが近道です。ラベルの欠落はenableAccessibilityChecks()で落ち、まとまりはonNodeWithTextでのクリックで確かめ、48dpはassertWidthIsAtLeastなどで測れます。Claudeには「落ちているテストを通す」ことを頼み、テストの期待値を弱めない制約を添えます。
最初の一歩は、画面を1つ選び、アイコンボタンのテストを1本だけ書くことです。失敗の出力を見れば、依頼文に書くべき条件が具体的になります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。
- developer.android.com/develop/ui/compose/accessibility/semantics
- developer.android.com/develop/ui/compose/accessibility/api-defaults
- developer.android.com/develop/ui/compose/accessibility/testing
- code.claude.com/docs/en/common-workflows
- code.claude.com/docs/en/best-practices
- code.claude.com/docs/en/hooks-guide