移動順序を制御する

デフォルトでは、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 は、TalkBack の移動順序をカスタマイズできる float プロパティです。要素をグループ化するだけで TalkBack が正しく機能しない場合は、traversalIndexisTraversalGroup と組み合わせて使用することで、スクリーン リーダーの順序をさらにカスタマイズできます。

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

  • traversalIndex の値が低い要素が優先されます。
  • 正の値または負の値を指定できます。
  • デフォルト値は 0f です。
  • テキストやボタンなどの画面上の要素など、スクリーン リーダーのフォーカス可能なノードにのみ影響します。たとえば、ある列に traversalIndex のみを設定しても、列に 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 → ボトム アプリバー

参考情報