Compose でのキーボード フォーカス管理

1. はじめに

ユーザーはハードウェア キーボードを使用してアプリを操作できます。通常はタブレットや ChromeOS デバイスなどの大画面デバイスで使用しますが、XR デバイスでも利用できます。ユーザーがハードウェア キーボードでもタッチスクリーンと同じように、快適にアプリを操作できるようにすることが重要です。また、タップ入力ではなく D-pad やロータリー エンコーダを使用する可能性のあるテレビや車載ディスプレイ向けにアプリを設計する場合も、同様のキーボード ナビゲーションの原則を適用する必要があります。

Compose を使用すると、ハードウェア キーボード、D-pad、ロータリー エンコーダからの入力を統一された方法で処理できます。これらの入力方法で優れたユーザー エクスペリエンスを実現するための重要な原則は、ユーザーが操作するインタラクティブなコンポーネントにキーボード フォーカスを直感的かつ一貫して移動できることです。

この Codelab では、次のことを学びます。

  • 直感的で一貫したナビゲーションを実現するための一般的なキーボード フォーカスの管理パターンを実装する方法
  • キーボード フォーカスの移動が想定どおりに動作するかどうかをテストする方法

前提条件

  • Compose を使ってアプリを構築した経験
  • Kotlin に関する基本的な知識(ラムダやコルーチンなど)

構築内容

次のような一般的なキーボード フォーカスの管理パターンを実装します。

  • キーボード フォーカスの移動 - 開始から終了まで、上から下に Z 字型のパターンで移動する
  • 論理的な当初のフォーカス - ユーザーが操作する可能性が高い UI 要素にフォーカスを設定する
  • フォーカスの復元 - ユーザーが以前操作した UI 要素にフォーカスを移動する

学習内容

  • Compose でのフォーカス管理の基本
  • UI 要素をフォーカス ターゲットにする方法
  • UI 要素にフォーカスを移動するリクエストの方法
  • グループ内の特定の UI 要素にキーボード フォーカスを移動する方法

必要なもの

  • Android Studio Ladybug 以降
  • サンプルアプリを実行する次のいずれかのデバイス:
  • ハードウェア キーボードを搭載した大画面デバイス
  • 大画面デバイス用の Android 仮想デバイス(サイズ変更可能なエミュレータなど)

2. 設定

  1. large-screen-codelabs の GitHub リポジトリのクローンを作成します。
git clone https://github.com/android/large-screen-codelabs

または、large-screen-codelabs の ZIP ファイルをダウンロードしてアーカイブを解除します。

  1. focus-management-in-compose フォルダに移動します。
  2. Android Studio でプロジェクトを開きます。focus-management-in-compose フォルダには 1 つのプロジェクトが含まれています。
  3. Android タブレット、折りたたみ式デバイス、ハードウェア キーボードを搭載した ChromeOS デバイスがない場合は、Android Studio で [デバイス マネージャー] を開き、[Phone] カテゴリで [Resizable] デバイスを作成します。

Android Studio のデバイス マネージャーには、phone カテゴリで利用可能な仮想デバイスのリストが表示されます。サイズ変更可能なエミュレータは、このカテゴリに該当します。図 1. Android Studio でサイズ変更可能なエミュレータを設定します。

3. スターター コードを確認する

このプロジェクトには 2 つのモジュールがあります。

  • start - プロジェクトのスターター コードが含まれています。このコードに変更を加えて Codelab を完了します。
  • solution - この Codelab の完成したコードが含まれています。

サンプルアプリは次の 3 つのタブで構成されています。

  • フォーカス ターゲット
  • フォーカス移動順序
  • フォーカス グループ

アプリの起動時に、[フォーカス ターゲット] タブが表示されます。

サンプルアプリの最初のビュー。3 つのタブがあり、[フォーカス ターゲット] タブ(1 つ目)が選択されています。このタブには、列に並べられた 3 つのカードが表示されます。

図 2. アプリの起動時に [フォーカス ターゲット] タブが表示されます。

ui パッケージには、操作する以下の UI コードが含まれています。

  • App.kt - タブを実装します
  • tab.FocusTargetTab.kt - [フォーカス ターゲット] タブのコードが含まれます
  • tab.FocusTraversalOrderTab.kt - [フォーカス移動順序] タブのコードが含まれます
  • tab.FocusGroup.kt - [フォーカス グループ] タブのコードが含まれます
  • FocusGroupTabTest.kt - tab.FocusTargetTab.kt のインストルメンテーション テスト(ファイルは androidTest フォルダにあります)

4. フォーカス ターゲット

フォーカス ターゲットは、キーボード フォーカスを移動できる UI 要素です。ユーザーは Tab キーまたは方向キー(矢印キー)を使用してキーボード フォーカスを移動できます。

  • Tab キー - フォーカスは次のフォーカス ターゲットまたは前のフォーカス ターゲットに 1 次元で移動します。
  • 方向キー - フォーカスは 2 次元(上、下、左、右)に移動できます。

タブはフォーカス ターゲットです。サンプルアプリでは、タブがフォーカスを取得すると、タブの背景が視覚的に更新されます。

この GIF アニメーション ファイルは、キーボードのフォーカスが UI 要素間を移動する様子を示しています。3 つのタブを移動した後、1 番目のカードにフォーカスが移動します。

図 3. フォーカスがフォーカス ターゲットに移動すると、コンポーネントの背景が変化します。

インタラクティブな UI 要素はデフォルトでフォーカス ターゲットになる

インタラクティブ コンポーネントは、デフォルトでフォーカス ターゲットです。つまり、ユーザーがタップできる UI 要素はフォーカス ターゲットであるということです。

サンプルアプリの [フォーカス ターゲット] タブには 3 つのカードがあります。1 番目3 番目のカードはフォーカス ターゲットですが、2 番目のカードはフォーカス ターゲットではありません。ユーザーが Tab キーで 1 番目のカードから 3 番目のカードにフォーカスを移動すると、3 番目のカードの背景が更新されます。

この GIF アニメーションは、[フォーカス ターゲット] タブでキーボードのフォーカスが最初に移動する様子を示しています。ユーザーが 1 番目のカードで Tab キーを押すと、2 番目のカードをスキップして 3 番目のカードに移動します。

図 4. アプリのフォーカス ターゲットには2 番目のカードは含まれません。

2 番目のカードをフォーカス ターゲットに変更する

2 番目のカードをフォーカス ターゲットにするには、インタラクティブな UI 要素に変更します。最も簡単な方法は、次のように clickable 修飾子を使用することです。

  1. tabs パッケージの FocusTargetTab.kt を開きます。
  2. 次のように、clickable 修飾子を使用して SecondCard コンポーザブルを変更します。
@Composable
fun FocusTargetTab(
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        verticalArrangement = Arrangement.spacedBy(16.dp),
        modifier = modifier
    ) {
        FirstCard(
            onClick = onClick,
            modifier = Modifier.width(240.dp)
        )
        SecondCard(
            modifier = Modifier
                .width(240.dp)
                .clickable(onClick = onClick)
        )
        ThirdCard(
            onClick = onClick,
            modifier = Modifier.width(240.dp)
        )
    }
}

実行する

これで、ユーザーは1 番目3 番目のカードに加えて、2 番目のカードにもフォーカスを移動できるようになります。[フォーカス ターゲット] タブで試すことができます。Tab キーを使用して、1 番目のカードから 2 番目のカードにフォーカスを移動できることを確認します。

この GIF アニメーションは、変更後のキーボードのフォーカス移動を示しています。ユーザーが 1 番目のカードで Tab キーを押すと、1 番目のカードから移動します。

図 5. Tab キーを使用して、1 番目のカードから 2 番目のカードにフォーカスを移動します。

5. Z 字型パターンのフォーカス走査

左から右の言語設定では、ユーザーはキーボード フォーカスが左から右、上から下に移動することを想定しています。このフォーカス移動順序を「Z 字型パターン」と呼びます。

ただし、Compose はレイアウトを無視して、Tab キーの次のフォーカス ターゲットを決定します。代わりにコンポーズ可能な関数の呼び出し順序に基づいて 1 次元のフォーカス走査を使用します。

1 次元のフォーカス走査

1 次元のフォーカス移動順序は、アプリのレイアウトではなく、コンポーズ可能な関数の呼び出し順序に基づきます。

サンプルアプリでは、[フォーカス移動順序] タブでフォーカスが次の順序で移動します。

  1. 1 番目のカード
  2. 4 番目のカード
  3. 3 番目のカード
  4. 2 番目のカード

この GIF アニメーションは、キーボード フォーカスがユーザーの想定と異なる方法で移動する様子を示しています。1 番目のカードから 3 番目のカード、4 番目のカード、2 番目のカードの順に移動します。これはユーザーの期待に反する可能性もあります。

図 6. フォーカス走査は、コンポーズ可能な関数の順序に従います。

FocusTraversalOrderTab 関数は、サンプルアプリのフォーカス走査タブを実装しています。この関数は、カードのコンポーズ可能な関数(FirstCardFourthCardThirdCardSecondCard)を順番に呼び出します。

@Composable
fun FocusTraversalOrderTab(
    modifier: Modifier = Modifier
) {
    Row(
        horizontalArrangement = Arrangement.spacedBy(16.dp),
        modifier = modifier
    ) {
        Column(
            verticalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            FirstCard(
                onClick = onClick,
                modifier = Modifier.width(240.dp)
            )
            FourthCard(
                onClick = onClick,
                modifier = Modifier
                    .width(240.dp)
                    .offset(x = 256.dp)
            )
            ThirdCard(
                onClick = onClick,
                modifier = Modifier
                    .width(240.dp)
                    .offset(y = (-151).dp)
            )
        }
        SecondCard(
            modifier = Modifier.width(240.dp)
        )
    }
}

Z 字型パターンでのフォーカス移動

サンプルアプリの [フォーカス移動順序] タブで、次の手順に沿って Z 字型のフォーカス移動を統合できます。

  1. tabs.FocusTraversalOrderTab.kt を開く
  2. ThirdCard コンポーザブルと FourthCard コンポーザブルからオフセット修飾子を削除する
  3. タブのレイアウトを、現在の 2 列 1 行から 1 列 2 行に変更する
  4. FirstCard コンポーザブルと SecondCard コンポーザブルを 1 行目に移動する
  5. ThirdCard コンポーザブルと FourthCard コンポーザブルを 2 行目に移動する

変更後のコードは次のようになります。

@Composable
fun FocusTraversalOrderTab(
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        verticalArrangement = Arrangement.spacedBy(16.dp),
        modifier = modifier
    ) {
        Row(
            horizontalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            FirstCard(
                onClick = onClick,
                modifier = Modifier.width(240.dp),
            )
            SecondCard(
                onClick = onClick,
                modifier = Modifier.width(240.dp)
            )
        }
        Row(
            horizontalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            ThirdCard(
                onClick = onClick,
                modifier = Modifier.width(240.dp)
            )
            FourthCard(
                onClick = onClick,
                modifier = Modifier.width(240.dp)
            )
        }
    }
}

実行する

これで、ユーザーは Z 字型パターンでフォーカスを右から左、上から下に移動できるようになります。[フォーカス移動順序] タブで試して、Tab キーでフォーカスが次の順序で移動することを確認できます。

  1. 1 番目のカード
  2. 2 番目のカード
  3. 3 番目のカード
  4. 4 番目のカード

この GIF アニメーションは、変更後にキーボード フォーカスが移動する様子を示しています。左から右、上から下へ、Z オーダーで移動します。

図 7. Z 字型パターンのフォーカス走査。

6. focusGroup

[フォーカス グループ] タブで right 方向キーを使用すると、フォーカスが 1 番目のカードから 3 番目のカードに移動します。2 つのカードが並んで表示されないため、ユーザーには少しわかりにくいかもしれません。

この GIF アニメーションは、右方向キーを使用してキーボード フォーカスが 1 番目のカードから 3 番目のカードに移動する様子を示しています。これらの 2 つのカードは異なる行に配置されています。

図 8. 1 番目のカードから 3 番目のカードへの予期しないフォーカス移動。

レイアウト情報に基づく 2 次元フォーカス走査

方向キーを押すと、2 次元のフォーカス走査がトリガーされます。これは、ユーザーが D-padを使用してアプリを操作するときに、テレビでよく見られるフォーカス走査です。キーボードの矢印キーを押すと、D-pad によるナビゲーションを模倣して 2 次元のフォーカス走査もトリガーされます。

2 次元のフォーカス走査では、システムは UI 要素の幾何学的情報を参照し、フォーカスを移動するフォーカス ターゲットを決定します。たとえば、[フォーカス ターゲット] タブからdown 方向キーを使用するとフォーカスが 1 番目のカードに移動し、上方向キーを押すとフォーカスが再び [フォーカス ターゲット] タブに移動します。

この GIF は、下方向キーでフォーカスが [フォーカス ターゲット] タブから 1 番目のカードに移動し、上方向キーでタブに戻る様子を示しています。これらの 2 つのフォーカス ターゲットは垂直方向で最も近い位置にあります。

図 9. 下方向キーと上方向キーによるフォーカス走査。

2 次元フォーカス走査は、Tab キーによる 1 次元フォーカス走査とは異なり、ラップアラウンドしません。たとえば、2 番目のカードにフォーカスが移動したときに、ユーザーは下方向キーでフォーカスを移動できません。

この GIF では、カードの下にフォーカス ターゲットがないため、ユーザーが下方向キーを押してもフォーカスがそのまま 2 番目のカードに留まる様子を示しています。

図 10. 2 番目のカードにフォーカスがあるときは、下方向キーではフォーカスを移動できません。

同じレベルにあるフォーカス ターゲット

次のコードは、上記の画面を実装しています。フォーカス ターゲットは FirstCardSecondCardThirdCardFourthCard の 4 つです。これらの 4 つのフォーカス ターゲットは同じレベルにあり、レイアウト上では ThirdCardFirstCard の右隣にある最初の項目です。そのため、right 方向キーを使用すると、フォーカスは 1 番目のカードから 3 番目のカードに移動します。

@Composable
fun FocusGroupTab(
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        verticalArrangement = Arrangement.spacedBy(16.dp),
        modifier = modifier,
    ) {
        FirstCard(
            onClick = onClick,
            modifier = Modifier.width(208.dp)
        )
        Row(
            horizontalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            SecondCard(
                onClick = onClick,
                modifier = Modifier.width(208.dp)
            )
            ThirdCard(
                onClick = onClick,
                modifier = Modifier.width(208.dp)
            )
            FourthCard(
                onClick = onClick,
                modifier = Modifier.width(208.dp)
            )
        }
    }
}

focusGroup 修飾子を使用してフォーカス ターゲットをグループ化する

フォーカスの動きがわかりにくい場合は、次の手順で変更できます。

  1. tabs.FocusGroup.kt を開く
  2. FocusGroupTab コンポーズ可能な関数内で、focusGroup 修飾子を使用して Column コンポーズ可能な関数を変更します。

更新後のコードは次のようになります。

@Composable
fun FocusGroupTab(
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        verticalArrangement = Arrangement.spacedBy(16.dp),
        modifier = modifier,
    ) {
        FirstCard(
            onClick = onClick,
            modifier = Modifier.width(208.dp)
        )
        Row(
            horizontalArrangement = Arrangement.spacedBy(16.dp),
            modifier = Modifier.focusGroup(),
        ) {
            SecondCard(
                onClick = onClick,
                modifier = Modifier.width(208.dp)
            )
            ThirdCard(
                onClick = onClick,
                modifier = Modifier.width(208.dp)
            )
            FourthCard(
                onClick = onClick,
                modifier = Modifier.width(208.dp)
            )
        }
    }
}

focusGroup 修飾子は、変更されたコンポーネント内のフォーカス ターゲットで構成されるフォーカス グループを作成します。フォーカス グループ内のフォーカス ターゲットとフォーカス グループ外のフォーカス ターゲットは異なるレベルにあり、FirstCard コンポーザブルの右側にフォーカス ターゲットが配置されていません。そのため、right 方向キーを使用しても、1 番目のカードから他のカードにフォーカスを移動できません。

実行する

これで、サンプルアプリの [フォーカス グループ] タブでは right 方向キーを押しても、1 番目のカードから 3 番目のカードにフォーカスが移動しなくなりました。

7. フォーカスのリクエスト

ユーザーはキーボードや D-pad を使用して、任意の UI 要素を選択して操作することはできません。要素を操作する前に、キーボード フォーカスをインタラクティブ コンポーネントに移動する必要があります。

たとえば、カードを操作する前に、フォーカスを [フォーカス ターゲット] タブから 1 番目のカードに移動する必要があります。当初のフォーカスを論理的に設定することで、ユーザーの主なタスクを開始するアクションの数を減らすことができます。

この GIF アニメーションは、タブを選択した後、Tab キーを 3 回押して、タブ内の 1 番目のカードにキーボードのフォーカスを移動する必要があることを示しています。

図 11. Tab キーを 3 回押すと、フォーカスが 1 番目のカードに移動します。

FocusRequester でフォーカスをリクエストする

FocusRequester を使用して、UI 要素にフォーカスを移動するようにリクエストできます。requestFocus() メソッドを呼び出す前に、FocusRequester オブジェクトを UI 要素に関連付ける必要があります。

1 番目のカードに当初のフォーカスを設定

当初のフォーカスを 1 番目のカードに設定する手順は次のとおりです。

  1. tabs.FocusTarget.kt を開く
  2. コンポーズ可能な関数 FocusTargetTabfirstCard 値を宣言し、remember 関数から返された FocusRequester オブジェクトでその値を初期化する
  3. focusRequester 修飾子を使用してコンポーズ可能な関数 FirstCard を変更する
  4. firstCard 値を focusRequester 修飾子の引数として指定する
  5. Unit 値を指定してコンポーズ可能な関数 LaunchedEffect を呼び出す。LaunchedEffect コンポーズ可能な関数に渡されたラムダ内で firstCard 値に対して requestFocus() メソッドを呼び出す

FocusRequester オブジェクトが作成され、2 番目と 3 番目のステップで UI 要素に関連付けられる5 番目のステップでは、FocusdTargetTab コンポーザブルが初めてコンポーズされたときに、関連する UI 要素にフォーカスを移動するようリクエストされる

更新されたコードは次のようになります。

@Composable
fun FocusTargetTab(
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    val firstCard = remember { FocusRequester() }

    Column(
        verticalArrangement = Arrangement.spacedBy(16.dp),
        modifier = modifier
    ) {
        FirstCard(
            onClick = onClick,
            modifier = Modifier
                .width(240.dp)
                .focusRequester(focusRequester = firstCard)
        )
        SecondCard(
            modifier = Modifier
                .width(240.dp)
                .clickable(onClick = onClick)
        )
        ThirdCard(
            onClick = onClick,
            modifier = Modifier.width(240.dp)
        )
    }

    LaunchedEffect(Unit) {
        firstCard.requestFocus()
    }
}

実行する

これで、タブを選択すると、キーボード フォーカスが [フォーカス ターゲット] タブの 1 番目のカードに移動するようになりました。タブを切り替えて試してみてください。また、アプリの起動時に 1 番目のカードが選択されます。

この GIF アニメーションは、ユーザーが [フォーカス ターゲット] タブを選択すると、キーボード フォーカスが 1 番目のカードに自動的に移動する様子を示しています。

図 12. [フォーカス ターゲット] タブを選択すると、フォーカスが 1 番目のカードに移動します。

8. 選択したタブにフォーカスを移動する

キーボード フォーカスがフォーカス グループに入るときにフォーカス ターゲットを指定できます。たとえば、ユーザーがタブ行にフォーカスを移動したときに、選択したタブにフォーカスを移動できます。

この動作を実装する手順は次のとおりです。

  1. App.kt を開きます。
  2. コンポーズ可能な関数 AppfocusRequesters 値を宣言します。
  3. focusRequesters 値を初期化するには、FocusRequester オブジェクトのリストを返す remember 関数の戻り値を使用します。返されるリストの長さは Screens.entries の長さと同じにする必要があります。
  4. focusRequester 修飾子を使用して Tab コンポーザブルを変更し、focusRequester 値の各 FocusRequester オブジェクトを Tab コンポーザブルに関連付けます。
  5. focusProperties 修飾子と focusGroup 修飾子を使用して、PrimaryTabRow コンポーザブルを変更します。
  6. ラムダを focusProperties 修飾子に渡し、enter プロパティを別のラムダに関連付けます。
  7. enter プロパティに関連付けられたラムダから、focusRequesters 値で selectedTabIndex 値をインデックスとする FocusRequester を返します。

変更後のコードは次のようになります。

@Composable
fun App(
    modifier: Modifier = Modifier,
) {
    val context = LocalContext.current

    var selectedScreen by rememberSaveable { mutableStateOf(Screen.FocusTarget) }
    val selectedTabIndex = Screen.entries.indexOf(selectedScreen)
    val focusRequesters = remember {
        List(Screen.entries.size) { FocusRequester() }
    }

    Column(modifier = modifier) {
        PrimaryTabRow(
            selectedTabIndex = selectedTabIndex,
            modifier = Modifier
                .focusProperties {
                    enter = {
                        focusRequesters[selectedTabIndex]
                    }
                }
                .focusGroup()
        ) {
            Screen.entries.forEachIndexed { index, screen ->
                Tab(
                    selected = screen == selectedScreen,
                    onClick = { selectedScreen = screen },
                    text = { Text(stringResource(screen.title)) },
                    modifier = Modifier.focusRequester(focusRequester = focusRequesters[index])
                )
            }
        }
        when (selectedScreen) {
            Screen.FocusTarget -> {
                FocusTargetTab(
                    onClick = context::onCardClicked,
                    modifier = Modifier.padding(32.dp),
                )
            }

            Screen.FocusTraversalOrder -> {
                FocusTraversalOrderTab(
                    onClick = context::onCardClicked,
                    modifier = Modifier.padding(32.dp)
                )
            }

            Screen.FocusRestoration -> {
                FocusGroupTab(
                    onClick = context::onCardClicked,
                    modifier = Modifier.padding(32.dp)
                )
            }
        }
    }
}

フォーカスの移動は focusProperties 修飾子で制御できます。修飾子に渡されるラムダで、FocusProperties を変更します。変更された UI 要素にフォーカスがある状態で、ユーザーが Tab キーまたは方向キーを押すと、システムはフォーカス ターゲットを選択するために FocusProperties を参照します。

enter プロパティを設定すると、システムはプロパティに設定されたラムダを評価します。その結果、ラムダから返された FocusRequester オブジェクトに関連付けられている UI 要素にフォーカスを移動します。

実行する

これで、ユーザーがタブ行にフォーカスを移動すると、キーボード フォーカスが選択したタブに移動するようになりました。次の手順で試すことができます。

  1. アプリを実行する
  2. [フォーカス グループ] タブを選択する
  3. down 方向キーを使用して、1 番目のカードにフォーカスを移動する
  4. up 方向キーでフォーカスを移動する

図 13. 選択したタブにフォーカスが移動します。

9. フォーカスの復元

ユーザーは、タスクが中断されたときに簡単に再開できることを期待しています。フォーカスの復元は、中断からの復旧をサポートします。フォーカスの復元では、以前に選択された UI 要素にキーボード フォーカスが移動します。

フォーカスの復元の一般的なユースケースは、動画ストリーミング アプリのホーム画面です。画面には、カテゴリ内のムービーやテレビ番組のエピソードなど、動画コンテンツの複数のリストが表示されます。ユーザーはリストを閲覧して、興味深いコンテンツを見つけます。以前に確認したリストに戻って引き続き閲覧することもあります。フォーカスの復元を使用すると、ユーザーはリストで最後に見たアイテムにキーボード フォーカスを移動せずに、引き続き閲覧できます。

focusRestorer 修飾子でフォーカスをフォーカス グループに復元

focusRestorer 修飾子を使用して、フォーカス グループにフォーカスを保存して復元します。フォーカスがフォーカス グループから離れると、フォーカスは以前にフォーカスされていたアイテムの参照を保存します。その後、フォーカスがフォーカス グループに戻ると、以前フォーカスされていたアイテムにフォーカスが復元されます。

フォーカスの復元をフォーカス グループタブに統合

サンプルアプリの [フォーカス グループ] タブには、2 番目のカード3 番目のカード4 番目のカードを含む行があります。

この GIF アニメーションは、3 番目のカードがフォーカスされていた場合でも、キーボードのフォーカスが 1 番目のカードから 2 番目のカードに移動する様子を示しています。

図 14. 2 番目のカード3 番目のカード4 番目のカードを含むフォーカス グループ。

次の手順で、フォーカスの復元を行に統合できます。

  1. tab.FocusGroupTab.kt を開く
  2. FocusGroupTab コンポーザブルの Row コンポーザブルを focusRestorer 修飾子で変更します。修飾子は focusGroup 修飾子の前に呼び出す必要があります。

変更後のコードは次のようになります。

@Composable
fun FocusGroupTab(
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        verticalArrangement = Arrangement.spacedBy(16.dp),
        modifier = modifier,
    ) {
        FirstCard(
            onClick = onClick,
            modifier = Modifier.width(208.dp)
        )
        Row(
            horizontalArrangement = Arrangement.spacedBy(16.dp),
            modifier = Modifier
                .focusRestorer()
                .focusGroup(),
        ) {
            SecondCard(
                onClick = onClick,
                modifier = Modifier.width(208.dp)
            )
            ThirdCard(
                onClick = onClick,
                modifier = Modifier.width(208.dp)
            )
            FourthCard(
                onClick = onClick,
                modifier = Modifier.width(208.dp)
            )
        }
    }
}

実行する

これで、[フォーカス グループ] タブの行にフォーカスが戻ります。次の手順で試すことができます。

  1. [フォーカス グループ] タブを選択する
  2. 1 番目のカードにフォーカスを移動する
  3. Tab キーを押して 4 番目のカードにフォーカスを移動する
  4. up 方向キーを使用して 1 番目のカードにフォーカスを移動する
  5. Tab キーを押す

キーボード フォーカスは 4 番目のカードに移動します。これは、focusRestorer 修飾子がカードの参照を保存し、キーボード フォーカスが行に設定されたフォーカス グループに入るとフォーカスを復元するためです。

この GIF アニメーションは、キーボード フォーカスが行に戻ったときに、以前その行で選択していたカードにキーボード フォーカスが移動する様子を示しています。

図 15. 上方向キーを押してから Tab キーを押すと、フォーカスが 4 番目のカードに戻ります。

10. テストの作成

実装したキーボード フォーカス管理は、テストで確認できます。Compose には、UI 要素がフォーカスされているかどうかをテストし、UI コンポーネントでキー操作を行うための API が用意されています。詳細については、Jetpack Compose でのテストの Codelab をご覧ください。

フォーカス ターゲットタブのテスト

前のセクションでは、FocusTargetTab コンポーズ可能な関数を変更して、2 番目のカードをフォーカス ターゲットとして設定しました。前のセクションで手動で行った実装のテストを作成します。テストは次の手順で記述できます。

  1. FocusTargetTabTest.kt を開きます。次の手順で testSecondCardIsFocusTarget 関数を変更します。
  2. 1 番目のカードSemanticsNodeInteraction オブジェクトで requestFocus メソッドを呼び出し、フォーカスを 1 番目のカードに移動するようリクエストします。
  3. assertIsFocused() メソッドで 1 番目のカードがフォーカスされていることを確認します。
  4. performKeyInput メソッドに渡されたラムダ内で Key.Tab 値を指定して pressKey メソッドを呼び出し、Tab キーを押します。
  5. 2 番目のカードSemanticsNodeInteraction オブジェクトで assertIsFocused() メソッドを呼び出し、キーボード フォーカスが 2 番目のカードに移動するかどうかをテストします。

更新されたコードは次のようになります。

@OptIn(ExperimentalTestApi::class, ExperimentalComposeUiApi::class)
@Test
fun testSecondCardIsFocusTarget() {
    composeTestRule.setContent {
        LocalInputModeManager
            .current
            .requestInputMode(InputMode.Keyboard)
        FocusTargetTab(onClick = {})
    }
    val context = InstrumentationRegistry.getInstrumentation().targetContext

    // Ensure the 1st card is focused
    composeTestRule
        .onNodeWithText(context.getString(R.string.first_card))
        .requestFocus()
        .performKeyInput { pressKey(Key.Tab) }

    // Test if focus moves to the 2nd card from the 1st card with Tab key
    composeTestRule
        .onNodeWithText(context.getString(R.string.second_card))
        .assertIsFocused()
}

実行する

テストを実行するには、FocusTargetTest クラス宣言の左側に表示される三角形のアイコンをクリックします。詳しくは、Android Studio でテストするテストを実行するをご覧ください。

Android Studio に、[FocusTargetTabTest] を実行するためのコンテキスト メニューが表示されます。

11. 完了

これで、キーボードのフォーカス管理の構成要素について学習できました。

  • フォーカス ターゲット
  • フォーカス走査

フォーカス移動順序は、次の Compose 修飾子で制御できます。

  • focusGroup 修飾子
  • focusProperties 修飾子

ハードウェア キーボード、当初のフォーカス、フォーカスの復元を使用した UX の一般的なパターンを実装しました。これらのパターンは、次の API を組み合わせて実装できます。

  • FocusRequester クラス
  • focusRequester 修飾子
  • focusRestorer 修飾子
  • LaunchedEffectコンポーズ可能な関数

実装された UX は、インストルメンテーション テストで確認できます。Compose には、キー操作を実行し、SemanticsNode にキーボード フォーカスがあるかどうかをテストする方法が用意されています。

詳細