Zenn
🛣️

【Compose for TV】ユーザーを迷子にさせないフォーカス制御

に公開

本記事の内容

Compose for TV によって Android TV でも宣言的な UI 開発が可能になりました。

Tv Lazy Layout の廃止など、Compose for TV は着実に進化を遂げています。
しかし自然な UX を実現するための "フォーカス制御" に関しては、依然として実装の工夫が必要です。

本記事では、以下のような Master/Detail Flow の画面を例に、Compose for TV で必要になるフォーカス制御に関して解説します。

デフォルトのフォーカス制御ロジック

本題に入る前に、Compose for TV におけるデフォルトのフォーカス制御ロジックに関して説明します。

デフォルトのフォーカス制御ではシンプルに、 "入力した D-pad のキー方向で一番近い View" が次のフォーカス対象に選出されます。

イメージしやすいように、単純なグリッド形式の画面における実際のフォーカス対象の選出ロジックをまとめてみました。

キー方向に対応するフォーカス先 実際のフォーカス移動

...いかがでしょうか、このデフォルトのフォーカス制御ロジックだけで十分そうに見えませんか。

ですがこのロジックだけだと期待に反する挙動がいくつか生じます 😢

デフォルトのフォーカスロジックの限界

記事の最初で紹介した Master/Detail Flow の画面に話を戻します。

先ほど紹介した "View の位置関係を利用したフォーカス制御" だけだと、以下のような期待に反する挙動に繋がる場合があります。

左のリストに戻った際に別タブが選択される 右のリストで先頭のアイテムがフォーカスされない

View の位置関係的には全くもって正しいのですが、左のリストに戻った際には「現在選択中のタブ」にフォーカスが当たってほしいですし、右のリストの初期フォーカスは Detail item 1 に当たって欲しいところです。

期待されるフォーカス移動順 実際のフォーカス移動順

このような自然な UX を実現するためには、「View の位置関係を利用したフォーカス制御」は使えません。
左右のリストそれぞれで "フォーカス対象の保存""フォーカス対象の復元" を行う必要があります。

フォーカス対象の保存/復元

「フォーカス対象の保存」と「フォーカス対象の復元」を行うとなると、ややこしいロジックが必要になりそうな印象を受けます。

しかしなんと Compose for TV にはこれをよしなに行ってくれる Modifier の拡張関数が用意されています。

それが "focusRestorer" です 🎉

実際に使ってみましょう。
使い方は簡単で、LazyColumn の場合は Modifier.focusRestorer() を追加するだけです。
Column の場合は Modifier.focusRestorer() に加えて Modifier.focusGroup() も追加してあげましょう。

+@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun Content(modifier: Modifier = Modifier) {
  Row(
    modifier = modifier,
  ) {
    MasterList(
+     modifier = Modifier.focusRestorer(), // LazyColumn なので focusGroup() の呼び出しは不要
    )
    DetailList(
-     modifier = Modifier.weight(1f),
+     modifier = Modifier
+       .weight(1f)
+       .focusRestorer()
+       .focusGroup(), // Column なので focusGroup() も呼び出す
    )
  }
}

@Composable
private fun MasterList(modifier: Modifier = Modifier) {
  LazyColumn(
    modifier = modifier,
  ) {
    // items about Category
  }
}

@Composable
private fun DetailList(modifier: Modifier = Modifier) {
  Column(
    modifier = modifier,
  ) {
    // Detail Items
  }
}

なお focusRestorer の引数には、フォーカス対象の復元に失敗した際 (初期フォーカス等) のフォールバック先を指定することもできます。
今回はデフォルト値が設定されているので、フォーカス対象の復元に失敗した際には View の位置関係を利用したロジックでフォーカス対象が選出されます。

それでは実際に動かしてみましょう。

無事フォーカス対象の保存/復元が行えてそうですね 。
ただ、別タブ (Category 4) から右のリストにフォーカスを移動させようとしたタイミングでクラッシュしていました 😢
ここで少し実装を工夫する必要があります。

再生成したコンポジションに対してフォーカス位置復元を試みる

先ほどのクラッシュログを見てみましょう。

E  FATAL EXCEPTION: main
   Process: com.example.focuscontrolapp, PID: 6614
   java.lang.IllegalStateException: Release should only be called once
   	at androidx.compose.foundation.lazy.layout.LazyLayoutPinnableItem.release(LazyLayoutPinnableItem.kt:159)
   	at androidx.compose.ui.focus.FocusRestorerNode$onEnter$1.invoke-3ESFkO8(FocusRestorer.kt:112)
   	at androidx.compose.ui.focus.FocusRestorerNode$onEnter$1.invoke(FocusRestorer.kt:109)
        ...

どうやら PinnableItem を過度に release しようとしてしまっているようです。

Jetpack Compose は可能な限り既存のコンポジションと状態を再利用し、変更を最小限に抑えようとします。
その都合上、本来再利用すべきでない状態まで再利用してしまい、状態の不整合が発生する場合があります。

今回の場合だと、別タブ (Category 4) の選択によって、右側のリストに表示しているアイテムのサムネイルやテキストは変更されましたが「フォーカス対象の保存/復元」に必要な情報は更新されておらず、不正な状態になってしまっているようでした。
そのため、左側の別タブを選択した際には「フォーカス対象の保存/復元」に必要な情報までリセットする必要があります。

このような場合には key (Compose Runtime) を使うと良さそうです。
使い方のイメージとしては以下のような感じです。

@Composable
fun SampleText(
  id: String,
  modifier: Modifier = Modifier,
) {
+ key(id) {
    Text(
      modifier = modifier,
      text = "hello",
    )
+ }
}

keyremember のように引数で指定した値の変更を検知し、「現在のコンポジションを破棄した後に再構築する」という処理を行います。

今回の状態不整合は FocusRestorerNode が保持している pinnedHandle に起因するものだったので、Modifier.focusRestorer() を呼び出しているコンポジションが再生成されるようにしてみましょう。

@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun Content(modifier: Modifier = Modifier) {
  Row(
    modifier = modifier,
  ) {
    MasterList(
      modifier = Modifier.focusRestorer(), // アイテムの更新は行われないのでkeyは不要
    )
+   // `focusedTabId` の値が変わったら現在の DetailList を破棄して描画し直す
+   key(focusedTabId) {
      DetailList(
        modifier = Modifier
          .weight(1f)
          .focusRestorer()
          .focusGroup(), // Column なので focusGroup() も呼び出す
      )
+   }
  }
}

クラッシュを解消することができました🎉

しかしこの方法では、左側のタブにおける選択状況を切り替えるたびに右側のリストを0から描画し直しています。
そのためパフォーマンスとのトレードオフは考慮する必要がありそうです。

まとめ

TV アプリの基本的な構造である Master/Detail Flow の画面を例に、Compose for TV で必要になるフォーカス制御に関して説明しました。

Modifier.focusRestorer を使うと、フォーカス位置の保存/復元をいい感じにハンドリングしてくれるので非常に便利です。
ただし、コンポジションが誤って再利用されてしまうとクラッシュにつながってしまいます。
そのため、適切なタイミングでコンポジションを再生成させる必要があります。

フォーカス制御を行う処理として focusRestorer 以外にも focusPropertiesFocusRequester#restoreFocusedChild などもあり、これらを使うとより細かいフォーカス制御が可能になります。
(より細かいフォーカス制御を行おうとするほど、ソースコード中にフォーカス制御に関するロジックが増えていきます。そのため絶対的にどれが良いとは言えなさそうです)

Compose for TV は強力なライブラリですが TV ならではの制約もあり、ハマりポイントは存在しているように感じます。
私も試行錯誤しながら導入を進めている状態なので、これからも色々試しながら知見の共有をしていけられればと考えています。

Discussion

ログインするとコメントできます