Jetpack Compose のユーザー補助

この Codelab では、Jetpack Compose を使用してアプリのユーザー補助を改善する方法を学びます。一般的なユースケースを紹介してから、サンプルアプリを順を追って改善していきます。タップ ターゲットのサイズ、内容説明、クリックラベルなどについても説明します。

視覚、色覚、または聴覚に障がいのある方、細かい作業に支障のある方、認知障がいのある方など、障がいのある多くの方々が Android デバイスを使って、普段の生活でさまざまな操作を行っています。ユーザー補助を念頭に置いてアプリを開発すると、特に上記やその他の補助が必要なユーザーのエクスペリエンスを高めることができます。

この Codelab では、TalkBack を使用し、変更したコードを手動でテストします。TalkBack は、主に視覚に障がいのある方が使用するユーザー補助サービスです。スイッチ アクセスなど、他のユーザー補助サービスでも変更後のコードをテストしてください。

TalkBack のフォーカス長方形が Jetnews のホーム画面内を移動しています。TalkBack が読み上げるテキストが画面の下部に表示されます。

Jetnews アプリでの TalkBack の動作。

学習内容

この Codelab では、以下について学びます。

  • タッチ ターゲットのサイズを大きくして、細かい作業が困難なユーザーに対応する方法。
  • セマンティクス プロパティとその変更方法。
  • コンポーザブルに情報を与えてユーザー補助を改善する方法。

必要なもの

作成するアプリの概要

この Codelab では、ニュース リーダー アプリのユーザー補助を改善します。まずは重要なユーザー補助機能がないアプリから始め、そこで学んだことを活かして、ユーザー補助を必要とするユーザーにとって、より使いやすくなるように改善します。

このステップでは、シンプルなニュース リーダー アプリを構成するコードをダウンロードします。

必要なもの

コードを取得する

この Codelab のコードは、android-compose-codelabs GitHub リポジトリにあります。クローンを作成するには、次のコマンドを実行します。

$ git clone https://github.com/googlecodelabs/android-compose-codelabs

または、次の 2 つの zip ファイルをダウンロードします。

サンプルアプリを確認する

ダウンロードしたコードには、利用可能なすべての Compose Codelab のコードが含まれています。この Codelab を完了するには、Android Studio 内で AccessibilityCodelab プロジェクトを開きます。

main ブランチのコードから始め、ご自身のペースで Codelab を進めることをおすすめします。

TalkBack のセットアップ

この Codelab では、TalkBack を使用して変更結果を確認します。実機を使用してテストする場合は、こちらの手順で TalkBack をオンにしてください。エミュレータにはデフォルトで TalkBack がインストールされていません。Play ストアを搭載したエミュレータを選択し、Android ユーザー補助設定ツールをダウンロードしてください。

クリック、タップなど、ユーザーが操作できる画面上の要素はすべて、確実に操作できるよう十分な大きさにする必要があります。これらの要素の幅と高さは 48 dp 以上にしてください。

これらのコントロールのサイズを動的に変更する場合や、コンテンツのサイズに基づいて変更する場合は、sizeIn 修飾子を使用して寸法の下限を設定することをおすすめします。

一部のマテリアル コンポーネントでは、これらのサイズが自動的に設定されます。たとえば、Button コンポーザブルは MinHeight が 36 dp に設定されており、また、8 dp の垂直パディングを使用します。すると、合計で必要な高さである 48 dp を満たします。

サンプルアプリを開いて TalkBack を実行すると、投稿カードの × アイコンのタップ ターゲットが非常に小さいことがわかります。このタップ ターゲットが 48 dp 以上になるようにしましょう。

左が元のアプリのスクリーンショット、右が改善後のソリューションです。

リストアイテムの比較。左が小さな輪郭の × アイコン、右が大きな輪郭の × アイコン。

実装を確認して、このコンポーザブルのサイズを確認しましょう。PostCards.kt を開き、PostCardHistory コンポーザブルを探します。ご覧のとおり、この実装ではオーバーフロー メニュー アイコンのサイズが 24 dp に設定されています。

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
   // ...

   Row(
       // ...
   ) {
       // ...
       CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
           Icon(
               imageVector = Icons.Default.Close,
               contentDescription = stringResource(R.string.cd_show_fewer),
               modifier = Modifier
                   .clickable { openDialog = true }
                   .size(24.dp)
           )
       }
   }
   // ...
}

この Icon のタップ ターゲットのサイズを大きくするには、パディングを追加します。

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
   // ...
   Row(
       // ...
   ) {
       // ...
       CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
           Icon(
               imageVector = Icons.Default.Close,
               contentDescription = stringResource(R.string.cd_show_fewer),
               modifier = Modifier
                   .clickable { openDialog = true }
                   .padding(12.dp)
                   .size(24.dp)
           )
       }
   }
   // ...
}

このユースケースでは、より簡単な方法でタップ ターゲットを 48 dp 以上に設定できます。それは、マテリアル コンポーネントの IconButton を利用する方法です。

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
   // ...
   Row(
       // ...
   ) {
       // ...
       CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
           IconButton(onClick = { openDialog = true }) {
               Icon(
                   imageVector = Icons.Default.Close,
                   contentDescription = stringResource(R.string.cd_show_fewer)
               )
           }
       }
   }
   // ...
}

TalkBack で画面を見てみると、48 dp のタップ ターゲット領域が正しく表示されるようになっています。さらに、IconButton により、要素がクリック可能であることを示すリップル インジケーションも追加されています。

デフォルトでは、アプリ内のクリック可能要素からクリック時の動作に関する情報を得ることはできません。そのため、TalkBack のようなユーザー補助サービスでは、非常に汎用的なデフォルトの説明が使用されます。

ユーザー補助を必要とするユーザーが快適に利用できるように、この要素のクリック時の動作に関する具体的な説明を設定できます。

Jetnews アプリでは、さまざまな投稿カードをクリックして、投稿の全文を読むことができます。デフォルトでは、クリック可能な要素の内容が読み上げられ、その後に「Double tap to activate」というテキストが読み上げられます。代わりに、より具体的な「Double tap to read article」を使用するようにしましょう。元のバージョンとこの理想的なソリューションを比較すると、次のようになります。

TalkBack を有効にした画面の録画が 2 つ並んでいます。垂直リストで投稿をタップし、水平カルーセルで投稿をタップしています。

コンポーザブルのクリックラベルの変更。変更前(左)と変更後(右)。

SurfaceCard などのコンポーザブルと、clickable 修飾子には、このクリックラベルを直接設定できるパラメータがあります。

PostCardHistory の実装をもう一度見てみましょう。

@Composable
fun PostCardHistory(
   // ...
) {
   Row(
       Modifier.clickable { navigateToArticle(post.id) }
   ) {
       // ...
   }
}

ご覧のとおり、この実装では clickable 修飾子が使用されています。クリックラベルを設定するには、onClickLabel パラメータを設定します。

@Composable
fun PostCardHistory(
   // ...
) {
   Row(
       Modifier.clickable(
               // R.string.action_read_article = "read article"
               onClickLabel = stringResource(R.string.action_read_article)
           ) {
               navigateToArticle(post.id)
           }
   ) {
       // ...
   }
}

TalkBack で「Double tap to read article」と読み上げられるようになりました。

ホーム画面の他の投稿カードにも同じ汎用的なクリックラベルが付いています。PostCardPopular コンポーザブルの実装を確認し、そのクリックラベルを更新しましょう。

@Composable
fun PostCardPopular(
   // ...
) {
   Card(
       shape = MaterialTheme.shapes.medium,
       modifier = modifier.size(280.dp, 240.dp),
       onClick = { navigateToArticle(post.id) }
   ) {
       // ...
   }
}

このコンポーザブルでは、内部で Card コンポーザブルを使用しています。これには、クリックラベルを渡すことができるオーバーロードがあります。

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun PostCardPopular(
   post: Post,
   navigateToArticle: (String) -> Unit,
   modifier: Modifier = Modifier
) {
   Card(
       shape = MaterialTheme.shapes.medium,
       modifier = modifier.size(280.dp, 240.dp),
       onClick = { navigateToArticle(post.id) },
       onClickLabel = stringResource(id = R.string.action_read_article)
   ) {
       // ...
   }
}

多くのアプリでは、なんらかのリストが表示され、リスト内の各アイテムには 1 つ以上のアクションが含まれています。スクリーン リーダーを使用する場合、同じアクションに何度もフォーカスがあたるため、そのようなリスト内移動が煩わしいものになります。

代わりに、コンポーザブルにカスタムのユーザー補助アクションを追加できます。こうすることで、同じリスト項目に関連するアクションをグループ化できます。

Jetnews アプリでは、読むことができる記事のリストを表示しています。各リスト項目には、ユーザーがこのトピックの表示を減らしたいことを示すアクションがあります。ここでは、このアクションをカスタム ユーザー補助アクションに移動することで、リスト内の移動を改善します。

左側のデフォルトの状況では、各クロスアイコンがフォーカス可能になっています。右側のソリューションでは、そのアクションが TalkBack のカスタム アクションにあります。

TalkBack を有効にした画面の録画が 2 つ並んでいます。左側の画面では、投稿アイテムのクロスアイコンが選択可能になっています。ダブルタップするとダイアログが開きます。右側の画面では、トリプルタップ ジェスチャーを使用してカスタムのアクション メニューを開いています。アクション [Show fewer of this] をタップすると、同じダイアログが開きます。

投稿アイテムにカスタム アクションを追加する。変更前(左)と変更後(右)。

PostCards.kt を開き、PostCardHistory コンポーザブルの実装を見てみましょう。RowIconButton のクリック可能プロパティに注意してください。Modifier.clickableonClick を使用しています。

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
   // ...
   Row(
       Modifier.clickable(
           onClickLabel = stringResource(R.string.action_read_article)
       ) {
           navigateToArticle(post.id)
       }
   ) {
       // ...
       CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
           IconButton(onClick = { openDialog = true }) {
               Icon(
                   imageVector = Icons.Default.Close,
                   contentDescription = stringResource(R.string.cd_show_fewer)
               )
           }
       }
   }
   // ...
}

デフォルトでは、RowIconButton のコンポーザブルはクリック可能であるため、TalkBack によってフォーカスがあてられます。この動作はリスト内の各アイテムに発生するため、リスト内の移動中に何度もスワイプすることになります。代わりに、IconButton に関連するアクションがカスタム アクションとしてリストアイテムに含まれるようにします。clearAndSetSemantics 修飾子を使用することで、この Icon とやり取りしないようユーザー補助サービスに伝えることができます。

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
   // ...
   Row(
       Modifier.clickable(
           onClickLabel = stringResource(R.string.action_read_article)
       ) {
           navigateToArticle(post.id)
       }
   ) {
       // ...
       CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
            IconButton(
                modifier = Modifier.clearAndSetSemantics { },
                onClick = { openDialog = true }
            ) {
                Icon(
                    imageVector = Icons.Default.Close,
                    contentDescription = stringResource(R.string.cd_show_fewer)
                )
            }
       }
   }
   // ...
}

しかし、IconButton のセマンティクスを削除したことにより、アクションを実行する方法はなくなりました。代わりに semantics 修飾子にカスタム アクションを追加することで、リスト項目にアクションを追加できます。

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
   // ...
   val showFewerLabel = stringResource(R.string.cd_show_fewer)
   Row(
        Modifier
            .clickable(
                onClickLabel = stringResource(R.string.action_read_article)
            ) {
                navigateToArticle(post.id)
            }
            .semantics {
                customActions = listOf(
                    CustomAccessibilityAction(
                        label = showFewerLabel,
                        // action returns boolean to indicate success
                        action = { openDialog = true; true }
                    )
                )
            }
   ) {
       // ...
       CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
            IconButton(
                modifier = Modifier.clearAndSetSemantics { },
                onClick = { openDialog = true }
            ) {
                Icon(
                    imageVector = Icons.Default.Close,
                    contentDescription = stringResource(R.string.cd_show_fewer)
                )
            }
       }
   }
   // ...
}

これで、TalkBack でカスタム アクションのポップアップを使用してアクションを適用できるようになります。これはリスト項目のアクションが多い場合ほど役に立ちます。

アプリのすべてのユーザーが、アプリに表示される視覚的要素(アイコンやイラストなど)を見たり解釈したりできるわけではありません。また、ユーザー補助サービスには、ピクセルのみに基づいて視覚的要素を認識する機能もありません。そのため、デベロッパーは、アプリの視覚的要素に関する詳細情報を、より多くユーザー補助サービスに渡す必要があります。

ImageIcon などの視覚的コンポーザブルには、パラメータ contentDescription があります。ここでは、その視覚的要素のローカライズ済みの説明を渡します。純粋に装飾的な要素の場合は、null を渡します。

このアプリの場合、内容説明の一部が記事画面に表示されません。アプリを実行し、トップ記事を選択して記事画面に移動しましょう。

TalkBack を有効にした画面の録画が 2 つ並んでいます。記事画面で [戻る] ボタンをタップしています。左は「Button—double tap to activate」と読み上げています。右は「Navigate up—double tap to activate」と読み上げています。

視覚的な内容説明を追加する。変更前(左)と変更後(右)。

情報を提供しなかった場合、左上のナビゲーション アイコンでは「Button, double tap to activate」と読み上げられます。これでは、そのボタンを有効にしたときのアクションについて、ユーザーに何も伝わりません。ArticleScreen.kt を開きましょう。

@Composable
fun ArticleScreen(
   // ...
) {
   // ...
   Scaffold(
       topBar = {
           InsetAwareTopAppBar(
               title = {
                   // ...
               },
               navigationIcon = {
                   IconButton(onClick = onBack) {
                       Icon(
                           imageVector = Icons.Filled.ArrowBack,
                           contentDescription = null
                       )
                   }
               }
           )
       }
   ) {
       // ...
   }
}

意味のある内容説明を Icon に追加します。

@Composable
fun ArticleScreen(
   // ...
) {
   // ...
   Scaffold(
       topBar = {
           InsetAwareTopAppBar(
               title = {
                   // ...
               },
               navigationIcon = {
                   IconButton(onClick = onBack) {
                       Icon(
                           imageVector = Icons.Filled.ArrowBack,
                           contentDescription = stringResource(
                               R.string.cd_navigate_up
                           )
                       )
                   }
               }
           )
       }
   ) {
       // ...
   }
}

この記事のもう 1 つの視覚的要素はヘッダー画像です。今回は、この画像は純粋に装飾的なもので、ユーザーに伝える必要のあるものは表示されません。このため、内容説明は null に設定され、ユーザー補助サービスを使用する際、この要素はスキップされます。

画面の最後の視覚的要素はプロフィール写真です。今回は一般的なアバターを使用しているため、ここに内容説明を追加する必要はありません。執筆者の実際のプロフィール写真を使用するときは、この写真の適切な内容説明を提供するように依頼してください。

この記事画面のように画面に多数のテキストがある場合、視覚に障がいのあるユーザーが目的のセクションをすばやく見つけるのは非常に困難です。それを実現するために、テキストのどの部分が見出しなのかを示します。上または下にスワイプすると、見出し間をすばやく移動できます。

デフォルトでは、見出しとして指定されているコンポーザブルはないため、ナビゲーションは使用できません。記事画面で、見出しから見出しへのナビゲーションを提供しましょう。

TalkBack を有効にした画面の録画が 2 つ並んでいます。下にスワイプして見出し間を移動しています。左の画面では「No next heading」と読み上げられます。右の画面では、見出しを順番に確認し、それぞれが読み上げられます。

見出しの追加。変更前(左)と変更後(右)。

今回は、記事の見出しは PostContent.kt で定義されています。このファイルを開いて、Paragraph コンポーザブルまでスクロールします。

@Composable
private fun Paragraph(paragraph: Paragraph) {
   // ...
   Box(modifier = Modifier.padding(bottom = trailingPadding)) {
       when (paragraph.type) {
           // ...
           ParagraphType.Header -> {
               Text(
                   modifier = Modifier.padding(4.dp),
                   text = annotatedString,
                   style = textStyle.merge(paragraphStyle)
               )
           }
           // ...
       }
   }
}

ここでは、Header は単純な Text コンポーザブルとして定義されています。heading セマンティクス プロパティを設定して、このコンポーザブルが見出しであることを示すことができます。

@Composable
private fun Paragraph(paragraph: Paragraph) {
   // ...
   Box(modifier = Modifier.padding(bottom = trailingPadding)) {
       when (paragraph.type) {
           // ...
           ParagraphType.Header -> {
               Text(
                   modifier = Modifier.padding(4.dp)
                     .semantics { heading() },
                   text = annotatedString,
                   style = textStyle.merge(paragraphStyle)
               )
           }
           // ...
       }
   }
}

前のステップで説明したように、TalkBack などのユーザー補助サービスでは、画面要素を要素ごとに移動します。デフォルトでは、Jetpack Compose の下位レベルのコンポーザブルのうち、1 つ以上のセマンティクス プロパティを設定するものは、それぞれフォーカスを受け取ります。たとえば、Text コンポーザブルは text セマンティクス プロパティを設定するため、フォーカスを受け取ります。

しかし、フォーカス可能な要素が画面上に多すぎると、ユーザーが 1 つずつ移動することになり、混乱してしまう可能性があります。代わりに、mergeDescendants プロパティを伴う semantics 修飾子を使用するとコンポーザブルをマージできます。

記事画面を確認しましょう。ほとんどの要素には、適切なレベルのフォーカスがあります。しかし、記事のメタデータは、現在のところ複数の個別の項目として読み上げられています。これは、フォーカス可能な 1 つのエンティティにマージすることで改善できます。

TalkBack を有効にした画面の録画が 2 つ並んでいます。左の画面では、Author フィールドと Metadata フィールドに、緑色の TalkBack の四角形が別々に表示されています。右の画面では、両方のフィールドを囲む 1 つの長方形が表示され、連結された内容が読み上げられます。

コンポーザブルのマージ。変更前(左)と変更後(右)。

PostContent.kt を開き、PostMetadata コンポーザブルを確認しましょう。

@Composable
private fun PostMetadata(metadata: Metadata) {
   // ...
   Row {
       Image(
           // ...
       )
       Spacer(Modifier.width(8.dp))
       Column {
           Text(
               // ...
           )

           CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
               Text(
                   // ..
               )
           }
       }
   }
}

次のように、最上位の行に対して子をマージするよう指定すると、望みの動作を実現できます。

@Composable
private fun PostMetadata(metadata: Metadata) {
   // ...
   Row(Modifier.semantics(mergeDescendants = true) {}) {
       Image(
           // ...
       )
       Spacer(Modifier.width(8.dp))
       Column {
           Text(
               // ...
           )

           CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
               Text(
                   // ..
               )
           }
       }
   }
}

SwitchCheckbox などの切り替え可能な要素は、TalkBack によって選択されると、その切り替え状態が読み上げられます。コンテキストがないと、切り替え可能な要素が何を意味するのかを把握するのが難しくなります。切り替え可能な状態を引き上げることで、切り替え可能な要素のコンテキストを含めることができ、コンポーザブル自体かそれを説明するラベルをタップすることで Switch または Checkbox を切り替えられるようになります。

Interests カテゴリの画面例を見てみましょう。ホーム画面からナビゲーション ドロワーを開くと、そこに移動します。[Interests] 画面には、ユーザーが購読できるトピックのリストが表示されます。デフォルトでは、この画面上のチェックボックスはラベルとは別にフォーカスされるため、コンテキストの把握が難しくなります。そこで、Row 全体を切り替え可能にします。

TalkBack を有効にした画面の録画が 2 つ並んでいます。Interests 画面に選択可能なトピックのリストが表示されています。左の画面では、TalkBack はチェックボックスを個別に選択しています。右の画面では、TalkBack は行全体を選択しています。

チェックボックスの取り扱い。変更前(左)と変更後(右)。

InterestsScreen.kt を開き、TopicItem コンポーザブルの実装を見てみましょう。

@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
   // ...
   Row(
       modifier = Modifier
           .padding(horizontal = 16.dp, vertical = 8.dp)
   ) {
       // ...
       Checkbox(
           checked = selected,
           onCheckedChange = { onToggle() },
           modifier = Modifier.align(Alignment.CenterVertically)
       )
   }
}

このように、Checkbox には要素の切り替えを処理する onCheckedChange コールバックがあります。このコールバックは、次のようにして Row 全体のレベルに引き上げることができます。

@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
   // ...
   Row(
       modifier = Modifier
           .toggleable(
               value = selected,
               onValueChange = { _ -> onToggle() },
               role = Role.Checkbox
           )
           .padding(horizontal = 16.dp, vertical = 8.dp)
   ) {
       // ...
       Checkbox(
           checked = selected,
           onCheckedChange = null,
           modifier = Modifier.align(Alignment.CenterVertically)
       )
   }
}

前のステップでは、切り替え動作を Checkbox から親の Row に引き上げました。コンポーザブルの状態に関するカスタムの説明を追加することで、この要素のユーザー補助をさらに改善できます。

デフォルトでは、Checkbox のステータスは「Ticked」と「Not ticked」のいずれかで読み上げられます。この説明は、独自のカスタムの説明に置き換えることができます。

TalkBack を有効にした画面の録画が 2 つ並んでいます。Interests 画面でトピックをタップしています。左の画面では「Not ticked」と読み上げられていますが、右の画面では「Not subscribed」と読み上げられています。

状態説明の追加。変更前(左)と変更後(右)。

前のステップで修正した TopicItem コンポーザブルを続けて修正します。

@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
   // ...
   Row(
       modifier = Modifier
           .toggleable(
               value = selected,
               onValueChange = { _ -> onToggle() },
               role = Role.Checkbox
           )
           .padding(horizontal = 16.dp, vertical = 8.dp)
   ) {
       // ...
       Checkbox(
           checked = selected,
           onCheckedChange = null,
           modifier = Modifier.align(Alignment.CenterVertically)
       )
   }
}

semantics 修飾子内で stateDescription プロパティを使用すると、カスタムの状態説明を追加できます。

@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
   // ...
   val stateNotSubscribed = stringResource(R.string.state_not_subscribed)
   val stateSubscribed = stringResource(R.string.state_subscribed)
   Row(
       modifier = Modifier
           .semantics {
               stateDescription = if (selected) {
                   stateSubscribed
               } else {
                   stateNotSubscribed
               }
           }
           .toggleable(
               value = selected,
               onValueChange = { _ -> onToggle() },
               role = Role.Checkbox
           )
           .padding(horizontal = 16.dp, vertical = 8.dp)
   ) {
       // ...
       Checkbox(
           checked = selected,
           onCheckedChange = null,
           modifier = Modifier.align(Alignment.CenterVertically)
       )
   }
}

これで、この Codelab は終了です。Compose のユーザー補助について詳しく学習しました。タップ ターゲット、視覚的要素の説明、状態の説明について学習しました。クリックラベル、見出し、カスタム アクションを追加しました。カスタムのマージを追加する方法、スイッチとチェックボックスの扱い方について学習しました。ここで学んだことをアプリに応用することで、ユーザー補助が大幅に改善されます。

Compose パスウェイに関する他の Codelab や、Jetnews を含む他のコードサンプルをご確認ください。

ドキュメント

これらのトピックに関する詳細とガイダンスについては、以下のドキュメントをご覧ください。