Compose のユーザー補助

Compose で作成するアプリは、さまざまなニーズを持つ利用者に対するユーザー補助をサポートする必要があります。ユーザー補助サービスは、画面に表示される内容を、特定のニーズを持つ利用者に適した形式に変換するために使用されます。ユーザー補助サービスをサポートするため、アプリは UI 要素に関するセマンティック情報を公開する Android フレームワークの API を使用します。Android フレームワークは、このセマンティック情報をユーザー補助サービスに伝えます。個々のユーザー補助サービスは、ユーザーに対してアプリを説明するのに最も適した方法を選択できます。Android には、TalkBackスイッチ アクセスなど、いくつかのユーザー補助サービスが用意されています。

セマンティクス

Compose は、セマンティクス プロパティを使用してユーザー補助サービスに情報を渡します。セマンティクス プロパティは、ユーザーに表示される UI 要素に関する情報を提供します。TextButton のような組み込みコンポーザブルのほとんどは、コンポーザブルとその子から推測される情報をセマンティクス プロパティに入力します。toggleableclickable のようないくつかの修飾子も、特定のセマンティクス プロパティを設定します。しかし、フレームワークは、ユーザーに対して UI 要素を説明する方法を把握するために、より多くの情報を必要とする場合があります。

このドキュメントでは、Android フレームワークに対して情報を正しく説明するために、コンポーザブルに明示的に情報を追加する必要があるさまざまな状況について説明します。また、特定のコンポーザブルのためにセマンティクス情報を完全に置き換える方法についても説明します。このドキュメントは、Android のユーザー補助の基本を理解していることを前提としています。

一般的なユースケース

ユーザー補助を必要とする人がアプリを正しく使用できるようにするには、このページに記載されているベスト プラクティスに沿ってアプリを作成する必要があります。

タップ ターゲットの最小サイズを検討する

クリック、タップなど、ユーザーが操作できる画面上の要素はすべて、確実に操作できるよう十分な大きさにする必要があります。これらの要素のサイズを調整する際は、マテリアル デザインのユーザー補助のガイドラインを適切に遵守するため、最小サイズを必ず 48 dp に設定してください。

マテリアル コンポーネント(CheckboxRadioButtonSwitchSliderSurface など)は、この最小サイズを内部で設定しますが、これはコンポーネントがユーザー アクションを受け取れる場合に限ります。たとえば、CheckboxonCheckedChange パラメータを非 null 値に設定すると、幅と高さが 48 dp 以上のパディングが含まれます。

@Composable
private fun CheckableCheckbox() {
    Checkbox(checked = true, onCheckedChange = {})
}

onCheckedChange パラメータを null に設定すると、コンポーネントを直接操作できないため、パディングは含まれません。

@Composable
private fun NonClickableCheckbox() {
    Checkbox(checked = true, onCheckedChange = null)
}

SwitchRadioButtonCheckbox などの選択コントロールを実装する場合、通常は、クリック可能な動作を親コンテナにリフトし、コンポーザブルのクリック コールバックを null に設定して、toggleable または selectable 修飾子を親コンポーザブルに追加します。

@Composable
private fun CheckableRow() {
    MaterialTheme {
        var checked by remember { mutableStateOf(false) }
        Row(
            Modifier
                .toggleable(
                    value = checked,
                    role = Role.Checkbox,
                    onValueChange = { checked = !checked }
                )
                .padding(16.dp)
                .fillMaxWidth()
        ) {
            Text("Option", Modifier.weight(1f))
            Checkbox(checked = checked, onCheckedChange = null)
        }
    }
}

クリック可能なコンポーザブルのサイズがタップ ターゲットの最小サイズより小さい場合、Compose はタップ ターゲットのサイズを大きくします。これは、コンポーザブルの境界の外側にタップ ターゲットのサイズを拡大することで実現されます。

以下の例では、非常に小さいクリック可能な Box を作成します。タップ ターゲット領域は Box の境界を越えて自動的に拡張されるため、Box の横をタップしてもクリック イベントがトリガーされます。

@Composable
private fun SmallBox() {
    var clicked by remember { mutableStateOf(false) }
    Box(
        Modifier
            .size(100.dp)
            .background(if (clicked) Color.DarkGray else Color.LightGray)
    ) {
        Box(
            Modifier
                .align(Alignment.Center)
                .clickable { clicked = !clicked }
                .background(Color.Black)
                .size(1.dp)
        )
    }
}

異なるコンポーザブルのタップ領域が重なり合うのを防ぐため、コンポーザブルには常に十分な大きさの最小サイズを使用する必要があります。この例では、sizeIn 修飾子を使用して内部のボックスの最小サイズを設定しています。

@Composable
private fun LargeBox() {
    var clicked by remember { mutableStateOf(false) }
    Box(
        Modifier
            .size(100.dp)
            .background(if (clicked) Color.DarkGray else Color.LightGray)
    ) {
        Box(
            Modifier
                .align(Alignment.Center)
                .clickable { clicked = !clicked }
                .background(Color.Black)
                .sizeIn(minWidth = 48.dp, minHeight = 48.dp)
        )
    }
}

クリックラベルを追加する

クリックラベルを使用して、コンポーザブルのクリック動作に意味論的意味を追加できます。クリックラベルでは、ユーザーがコンポーザブルを操作したときの動作を説明します。ユーザー補助サービスでは、クリックラベルを使用して、特定のニーズがあるユーザーにアプリを説明します。

clickable 修飾子でパラメータを渡してクリックレベルを設定します。

@Composable
private fun ArticleListItem(openArticle: () -> Unit) {
    Row(
        Modifier.clickable(
            // R.string.action_read_article = "read article"
            onClickLabel = stringResource(R.string.action_read_article),
            onClick = openArticle
        )
    ) {
        // ..
    }
}

また、clickable 修飾子にアクセスできない場合は、semantics 修飾子でクリックラベルを設定できます。

@Composable
private fun LowLevelClickLabel(openArticle: () -> Boolean) {
    // R.string.action_read_article = "read article"
    val readArticleLabel = stringResource(R.string.action_read_article)
    Canvas(
        Modifier.semantics {
            onClick(label = readArticleLabel, action = openArticle)
        }
    ) {
        // ..
    }
}

視覚要素を説明する

Image または Icon コンポーザブルを定義する際に、何が表示されているかを Android フレームワークが自動的に認識する方法はありません。視覚要素のテキスト説明を渡す必要があります。

ユーザーが現在のページを友だちと共有できる画面があるとします。この画面には、クリック可能な共有アイコンがあります。

クリック可能なアイコンのストリップ。アイコンには

Android フレームワークは、アイコンだけでは、視覚障がいのあるユーザーへの説明方法を認識できません。Android フレームワークは、アイコンに関する追加のテキスト説明を必要とします。

視覚要素を説明するには、contentDescription パラメータを使用します。説明はユーザーに伝えられるものなので、ローカライズした文字列を使用する必要があります。

@Composable
private fun ShareButton(onClick: () -> Unit) {
    IconButton(onClick = onClick) {
        Icon(
            imageVector = Icons.Filled.Share,
            contentDescription = stringResource(R.string.label_share)
        )
    }
}

一部の視覚要素は純粋に装飾的なものであるため、ユーザーに説明したくない場合があります。contentDescription パラメータを null に設定すると、この要素にはアクションまたは状態が関連付けられていないことを Android フレームワークに通知できます。

@Composable
private fun PostImage(post: Post, modifier: Modifier = Modifier) {
    val image = post.imageThumb ?: painterResource(R.drawable.placeholder_1_1)

    Image(
        painter = image,
        // Specify that this image has no semantic meaning
        contentDescription = null,
        modifier = modifier
            .size(40.dp, 40.dp)
            .clip(MaterialTheme.shapes.small)
    )
}

特定の視覚要素に contentDescription が必要かどうかは自由に決定できます。ユーザーがタスクを実行するために必要な情報が視覚要素に含まれているかどうかを検討してください。含まれていない場合は、説明を省略する方が適切です。

要素を結合する

TalkBack やスイッチ アクセスなどのユーザー補助サービスでは、ユーザーは画面上の要素間でフォーカスを移動できます。適切な粒度で要素がフォーカスされることが重要です。画面上で低レベルの単一コンポーザブルが個別にフォーカスされている場合、ユーザーは画面上を移動するために多くの操作を行う必要があります。要素を過剰に結合すると、ユーザーはどの要素が結合されているかを把握できなくなります。

clickable 修飾子をコンポーザブルに適用すると、それに含まれるすべての要素が Compose によって自動的に結合されます。これは ListItem にも当てはまります。リストアイテム内の要素が結合され、ユーザー補助サービスからは 1 つの要素として認識されます。

論理グループを形成するコンポーザブルのセットを持つことはできますが、そのグループはクリック可能ではなく、リストアイテムの一部でもありません。それでもユーザー補助サービスがそれらを 1 つの要素として認識するようにしたい場合があります。たとえば、ユーザーのアバター、名前、追加情報を表示するコンポーザブルを想像してください。

ユーザー名を含む UI 要素のグループ。名前が選択されています。

semantics 修飾子の mergeDescendants パラメータを使用して、これらの要素を結合するように Compose に指示できます。そうすると、ユーザー補助サービスは結合された要素のみを選択し、子孫のすべてのセマンティクス プロパティが結合されます。

@Composable
private fun PostMetadata(metadata: Metadata) {
    // Merge elements below for accessibility purposes
    Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
        Image(
            imageVector = Icons.Filled.AccountCircle,
            contentDescription = null // decorative
        )
        Column {
            Text(metadata.author.name)
            Text("${metadata.date} • ${metadata.readTimeMinutes} min read")
        }
    }
}

ユーザー補助サービスは、コンテナ全体を一度にフォーカスして、それらのコンテンツを結合します。

ユーザー名を含む UI 要素のグループ。すべての要素がまとめて選択されています。

カスタム アクションを追加する

以下のリストアイテムをご覧ください。

記事タイトル、著者、ブックマーク アイコンを含む一般的なリストアイテム。

TalkBack のようなスクリーン リーダーを使用して画面の表示内容の読み上げを聴くときは、まずアイテム全体を選択し、次にブックマーク アイコンを選択します。

すべての要素がまとめて選択されたリストアイテム。

ブックマーク アイコンのみが選択されたリストアイテム。

長いリストは、繰り返しが多い場合があります。そのような場合は、ユーザーがアイテムをブックマークするためのカスタム アクションを定義する方が適切です。ブックマーク アイコン自体の動作を明示的に削除して、ユーザー補助サービスによって選択されないようにすることも忘れないでください。そのためには、clearAndSetSemantics 修飾子を使用します。

@Composable
private fun PostCardSimple(
    /* ... */
    isFavorite: Boolean,
    onToggleFavorite: () -> Boolean
) {
    val actionLabel = stringResource(
        if (isFavorite) R.string.unfavorite else R.string.favorite
    )
    Row(
        modifier = Modifier
            .clickable(onClick = { /* ... */ })
            .semantics {
                // Set any explicit semantic properties
                customActions = listOf(
                    CustomAccessibilityAction(actionLabel, onToggleFavorite)
                )
            }
    ) {
        /* ... */
        BookmarkButton(
            isBookmarked = isFavorite,
            onClick = onToggleFavorite,
            // Clear any semantics properties set on this node
            modifier = Modifier.clearAndSetSemantics { }
        )
    }
}

要素の状態を説明する

コンポーザブルでは、Android フレームワークによって使用されるセマンティクスの stateDescription を定義して、コンポーザブルの状態を読み上げることができます。たとえば、切り替え可能なコンポーザブルの状態は、「オン」または「オフ」のいずれかです。Compose が使用するデフォルトの状態説明ラベルをオーバーライドしたい場合もあります。これを行うには、コンポーザブルを切り替え可能として定義する前に、状態説明ラベルを明示的に指定します。

@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
    val stateSubscribed = stringResource(R.string.subscribed)
    val stateNotSubscribed = stringResource(R.string.not_subscribed)
    Row(
        modifier = Modifier
            .semantics {
                // Set any explicit semantic properties
                stateDescription = if (selected) stateSubscribed else stateNotSubscribed
            }
            .toggleable(
                value = selected,
                onValueChange = { onToggle() }
            )
    ) {
        /* ... */
    }
}

見出しを定義する

アプリは、スクロール可能なコンテナで 1 つの画面に多数のコンテンツを表示することがあります。たとえば、1 つの画面にユーザーが読んでいる記事のすべての内容を表示する場合などです。

スクロール可能なコンテナで記事テキストを表示したブログ投稿のスクリーンショット。

ユーザー補助を必要とするユーザーにとって、そのような画面をナビゲートするのは困難です。ナビゲーションを補助するため、どの要素が見出しなのかを示すことができます。上記の例では、各サブセクションのタイトルをユーザー補助用の見出しとして定義できます。TalkBack のような一部のユーザー補助サービスでは、ユーザーは見出しから見出しに直接移動できます。

Compose で、あるコンポーザブルが見出しであることを示すには、セマンティクス プロパティを定義します。

@Composable
private fun Subsection(text: String) {
    Text(
        text = text,
        style = MaterialTheme.typography.headlineSmall,
        modifier = Modifier.semantics { heading() }
    )
}

ユーザー補助プロパティの自動テスト

アプリのセマンティック プロパティをカスタマイズする場合(たとえば、上記のユースケースに従う場合)は、自動 UI テストを使用して正確性を検証し、回帰を防ぐことができます。

たとえば、要素のクリックラベルが正しく設定されているかどうかをテストするには、次のコードを使用します。

@Test
fun test() {
    composeTestRule
        .onNode(nodeMatcher)
        .assert(
            SemanticsMatcher("onClickLabel is set correctly") {
                it.config.getOrNull(SemanticsActions.OnClick)?.label == "My Click Label"
            }
        )
}

低レベルのカスタム コンポーザブルを作成する

より高度なユースケースとして、アプリ内の特定のマテリアル コンポーネントをカスタム バージョンに置き換える場合があります。このシナリオでは、ユーザー補助の考慮事項を念頭に置くことが重要です。たとえば、マテリアル Checkbox を独自の実装で置き換える場合があります。その場合、このコンポーネントのユーザー補助プロパティを処理する triStateToggleable 修飾子を追加することを忘れがちです。

経験則上、マテリアル ライブラリのコンポーネントの実装を確認して、思いつく限りのユーザー補助動作を模倣する必要があります。さらに、UI レベルの修飾子ではなく、基盤修飾子を多用します。基盤修飾子は、すぐに使用できるユーザー補助の考慮事項に対応しているからです。カスタム コンポーネントの実装は、必ず複数のユーザー補助サービスでテストして、動作を検証してください。

isTraversalGrouptraversalIndex で移動順序を変更する

デフォルトでは、Compose アプリのユーザー補助スクリーン リーダーの動作は、想定される読み取り順序(通常は左から右、上から下)で実装されます。ただし、追加のヒントがなければ、アルゴリズムが実際の読み取り順序を決定できないタイプのアプリのレイアウトもあります。ビューベースのアプリでは、traversalBefore プロパティと traversalAfter プロパティを使用してこのような問題を修正できます。Compose 1.5 以降、Compose は同等の柔軟な API を提供しますが、新しいコンセプト モデルが追加されています。

isTraversalGrouptraversalIndex は、デフォルトの並べ替えアルゴリズムが適切でない場合に、ユーザー補助と TalkBack のフォーカス順序を制御できるセマンティック プロパティです。isTraversalGroup は意味的に重要なグループを識別し、traversalIndex はそれらのグループ内の個々の要素の順序を調整します。isTraversalGroup を単独で使用することも、traversalIndex と一緒に使用してさらにカスタマイズすることもできます。

このページでは、アプリで isTraversalGrouptraversalIndex を使用して、スクリーン リーダーの移動順序を制御する方法について説明します。

isTraversalGroup で要素をグループ化する

isTraversalGroup は、セマンティクス ノードが走査グループかどうかを定義するブール値プロパティです。このタイプのノードは、子を整理する際の境界または境界として機能するノードです。

ノードに isTraversalGroup = true を設定すると、他の要素に移動する前に、そのノードのすべての子にアクセスすることになります。列、行、ボックスなど、スクリーン リーダー以外のフォーカス可能なノードに isTraversalGroup を設定できます。

この例では、スニペットが isTraversalGroup を使用するように変更されています。以下のスニペットは 4 つのテキスト要素を出力します。左側の 2 つの要素は 1 つの CardBox 要素に属し、右側の 2 つの要素は別の CardBox 要素に属しています。

// CardBox() function takes in top and bottom sample text.
@Composable
fun CardBox(
    topSampleText: String,
    bottomSampleText: String,
    modifier: Modifier = Modifier
) {
    Box(modifier) {
        Column {
            Text(topSampleText)
            Text(bottomSampleText)
        }
    }
}

@Composable
fun TraversalGroupDemo() {
    val topSampleText1 = "This sentence is in "
    val bottomSampleText1 = "the left column."
    val topSampleText2 = "This sentence is "
    val bottomSampleText2 = "on the right."
    Row {
        CardBox(
            topSampleText1,
            bottomSampleText1
        )
        CardBox(
            topSampleText2,
            bottomSampleText2
        )
    }
}

このコードにより、次のような出力が生成されます。

2 列のテキストからなるレイアウト。左側の列には「This sentence is the left column」、右側の列には「This sentence is on the right」と読み上げられています。
図 1. 2 つの文(左の列に 1 つずつ、右の列に 1 つずつ)の文があるレイアウト

セマンティクスが設定されていないため、スクリーン リーダーのデフォルトの動作では、要素を左から右、上から下に走査します。このデフォルト設定により、TalkBack は文の断片を間違った順序で読み上げます。

「この文を挿入」→「この文は」→「左の列」。→「右側にあります」

フラグメントを正しく並べ替えるには、元のスニペットを変更して isTraversalGrouptrue に設定します。

@Composable
fun TraversalGroupDemo2() {
    val topSampleText1 = "This sentence is in "
    val bottomSampleText1 = "the left column."
    val topSampleText2 = "This sentence is"
    val bottomSampleText2 = "on the right."
    Row {
        CardBox(
//      1,
            topSampleText1,
            bottomSampleText1,
            Modifier.semantics { isTraversalGroup = true }
        )
        CardBox(
//      2,
            topSampleText2,
            bottomSampleText2,
            Modifier.semantics { isTraversalGroup = true }
        )
    }
}

isTraversalGroup は各 CardBox に特別に設定されているため、要素の並べ替え時に CardBox 境界が適用されます。この場合、左側の CardBox が最初に読み取られ、次に右側の CardBox が読み取られます。

これで、TalkBack が文の断片を正しい順序で読み上げるようになりました。

「この文は」→「左の列」にあります。→ 「この文は」 → 「右側」。

traversalIndex を使用して走査順序をさらにカスタマイズする

traversalIndex は、TalkBack の移動順序をカスタマイズできる浮動小数点プロパティです。要素をグループ化するだけで TalkBack が正しく機能しない場合は、traversalIndexisTraversalGroup と組み合わせて使用すると、スクリーン リーダーの順序をさらにカスタマイズできます。

traversalIndex プロパティには次の特性があります。

  • traversalIndex の値が低い要素が優先されます。
  • 正の値または負の値を指定できます。
  • デフォルト値は 0f です。
  • テキストやボタンなどの画面上の要素など、スクリーン リーダーのフォーカス可能なノードにのみ影響します。たとえば、Column に traversalIndex のみを設定しても、Column に isTraversalGroup も設定されていない限り、効果はありません。

次の例は、traversalIndexisTraversalGroup を併用する方法を示しています。

例: 時計の文字盤を走査する

文字盤は、標準的な移動順序が機能しない一般的なシナリオです。このセクションの例は、時刻選択ツールに基づいています。ユーザーは、文字盤の数字を走査して、時間と分のスロットの数字を選択できます。

上に時間選択ツールが表示された文字盤。
図 2. 文字盤の画像。

次の簡単なスニペットには CircularLayout があり、12 から始まり、円の周りを時計回りに 12 の数字が描画されます。

@Composable
fun ClockFaceDemo() {
    CircularLayout {
        repeat(12) { hour ->
            ClockText(hour)
        }
    }
}

@Composable
private fun ClockText(value: Int) {
    Box(modifier = Modifier) {
        Text((if (value == 0) 12 else value).toString())
    }
}

文字盤は、デフォルトの左から右および上から下の順序では論理的に読み取られないため、TalkBack は番号を順不同で読み上げます。これを修正するには、次のスニペットに示すように、カウンタ値をインクリメントします。

@Composable
fun ClockFaceDemo() {
    CircularLayout(Modifier.semantics { isTraversalGroup = true }) {
        repeat(12) { hour ->
            ClockText(hour)
        }
    }
}

@Composable
private fun ClockText(value: Int) {
    Box(modifier = Modifier.semantics { this.traversalIndex = value.toFloat() }) {
        Text((if (value == 0) 12 else value).toString())
    }
}

移動の順序を適切に設定するには、まず CircularLayout を走査グループにして、isTraversalGroup = true を設定します。次に、各時計のテキストがレイアウトに描画されるときに、対応する traversalIndex をカウンタ値に設定します。

カウンタ値は継続的に増加するため、画面に数字が追加されるにつれて各クロック値の traversalIndex は大きくなります(クロック値 0 の traversalIndex は 0、クロック値 1 の traversalIndex は 1 など)。これにより、TalkBack が読み上げる順序が設定されます。これで、CircularLayout 内の数値が想定どおりの順序で読み取られます。

設定されている traversalIndexes は同じグループ内の他のインデックスに対する相対値のみであるため、残りの画面の順序は保持されます。つまり、上記のコード スニペットに示すセマンティックの変更では、isTraversalGroup = true が設定されている文字盤内の順序のみが変更されます。

CircularLayout's セマンティクスを isTraversalGroup = true に設定しなくても、traversalIndex の変更は引き続き適用されます。ただし、それらをバインドする CircularLayout がないと、画面上の他のすべての要素がアクセスされた後に、文字盤の 12 桁の数字が最後に読み取られます。これは、他のすべての要素のデフォルトの traversalIndex0f であり、時計のテキスト要素が他のすべての 0f 要素の後に読み取られるためです。

例: フローティング アクション ボタンの移動順序をカスタマイズする

この例では、traversalIndexisTraversalGroup を使用して、マテリアル デザインのフローティング アクション ボタン(FAB)の移動順序を制御します。この例は、次のレイアウトに基づいています。

上部のアプリバー、サンプル テキスト、フローティング アクション ボタン、下部のアプリバーがあるレイアウト。
図 3. 上部のアプリバー、サンプル テキスト、フローティング アクション ボタン、下部のアプリバーがあるレイアウト

上記のレイアウトでは、デフォルトでは TalkBack が次の順序で表示されます。

トップ アプリバー → サンプル テキスト 0 ~ 6 → フローティング アクション ボタン(FAB) → 下部のアプリバー

スクリーン リーダーでまず FAB にフォーカスすることをおすすめします。FAB などのマテリアル要素に traversalIndex を設定するには、次の手順を行います。

@Composable
fun FloatingBox() {
    Box(modifier = Modifier.semantics { isTraversalGroup = true; traversalIndex = -1f }) {
        FloatingActionButton(onClick = {}) {
            Icon(imageVector = Icons.Default.Add, contentDescription = "fab icon")
        }
    }
}

このスニペットでは、ボックスを作成し、isTraversalGrouptrue に設定し、同じボックス上で traversalIndex を設定します(-1f はデフォルト値の 0f より小さい)。これは、フローティング ボックスが画面上の他のすべての要素の前に表示されることを意味します。

次に、シンプルなマテリアル デザイン レイアウトを実装する、フローティング ボックスとその他の要素をスキャフォールドに配置できます。

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ColumnWithFABFirstDemo() {
    Scaffold(
        topBar = { TopAppBar(title = { Text("Top App Bar") }) },
        floatingActionButtonPosition = FabPosition.End,
        floatingActionButton = { FloatingBox() },
        content = { padding -> ContentColumn(padding = padding) },
        bottomBar = { BottomAppBar { Text("Bottom App Bar") } }
    )
}

TalkBack は以下の順序で要素とやり取りします。

FAB → トップ アプリバー → サンプル テキスト 0 ~ 6 → ボトム アプリバー

詳細

Compose コードでのユーザー補助のサポートについて詳しくは、Jetpack Compose のユーザー補助の Codelab をご覧ください。