🏄‍♂️

【Jetpack Compose】脱 Tv Lazy Layout --- アニメーション編

2024/11/24に公開

本記事の内容

Android TV 向けのライブラリである tv-foundation 1.0.0-alpha11 にて、遂に Tv Lazy Layout が非推奨 になりました。

Tv Lazy Layout の引数である PivotOffsets に関して、デフォルト値を使用している場合は単に Tv のプレフィックスを外すだけで問題ないです。
しかし、デフォルトとは異なる値を設定している場合は、CompositionLocal を利用して BringIntoViewSpec インスタンスを配布する実装に変更する必要があります。
(詳しい内容は 移行前後の差分編 を参照してください🙏)

本記事ではこの Tv Lazy Layout から Lazy Layout の移行中にハマったポイントのうち、アニメーションに関する内容を記載します。

パート 内容
Part.1 移行前後の差分編
Part.2 アニメーション編
Part.3 テスト編

スクロールアニメーションの無効化

まず紹介するのはスクロールアニメーションの無効化です。

スクロールアニメーションを無効化しておくと、フォーカス移動に伴うスクロール処理は一切実行されなくなります。

スクロールアニメーションが有効 スクロールアニメーションが無効

利用用途に困りそうなこの機能ですが、初期フォーカスと組み合わせた使い方ができそうです。

初期フォーカスを当てた際にも BringIntoViewSpec によるスクロール処理が実行されてしまいます。
そのため、スクロールアニメーションが常時有効になっていると以下二つの仕様を同時に満たせられません。

  • ファーストビューにおけるスクロール位置は最上部とする
  • 2個目以降の item に初期フォーカスを当てる

初期フォーカスを当てるタイミングだけスクロールアニメーションを無効化させられると、以下の動画のようにこれら二つの仕様を同時に満たせられます。

初期フォーカス
(スクロールアニメーションが有効)
初期フォーカス
(スクロールアニメーションが無効)

少し前置きが長くなってしまいましたが、ここで述べたスクロールアニメーションにおける有効状態の設定方法が Tv Lazy Layout と Lazy Layout で異なります😵

【Tv Lazy Layout】 userScrollEnabled

移行前の Tv Lazy Layout での設定方法を見てみます。

まずは TvLazyColumn の引数[1]を確認してみましょう。

@Composable
fun TvLazyColumn(
    modifier: Modifier = Modifier,
    state: TvLazyListState = rememberTvLazyListState(),
    contentPadding: PaddingValues = PaddingValues(0.dp),
    reverseLayout: Boolean = false,
    verticalArrangement: Arrangement.Vertical =
        if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    userScrollEnabled: Boolean = true, // 怪しい👀
    pivotOffsets: PivotOffsets = PivotOffsets(),
    content: TvLazyListScope.() -> Unit
) { ... }

userScrollEnabled という如何にもな名前の Boolean 型引数がありますね。
コードコメントに記載してある引数の説明からも、スクロールアニメーションに関係したパラメータであることが伺えます。

whether the scrolling via the user gestures or accessibility actions is allowed. You can still scroll programmatically using the state even when it is disabled.

実際にこの引数の真偽値を変更すると、スクロールアニメーションにおける有効状態も変更できました🙌

userScrollEnabled == true userScrollEnabled == false

【Lazy Layout】 BringIntoViewSpec#calculateScrollDistance

移行後の Lazy Layout でもスクロールアニメーションの無効化に挑戦してみます。

引数のパラメータを変更してみる

まずは Tv Lazy Layout と同様に設定できないか探るため、LazyColumn の引数[2]を見てみます。

@Composable
fun LazyColumn(
    modifier: Modifier = Modifier,
    state: LazyListState = rememberLazyListState(),
    contentPadding: PaddingValues = PaddingValues(0.dp),
    reverseLayout: Boolean = false,
    verticalArrangement: Arrangement.Vertical =
        if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
    userScrollEnabled: Boolean = true, // 怪しい👀
    content: LazyListScope.() -> Unit
) { ... }

こちらにも userScrollEnabled がありますね。
コードコメントに記載してある引数の説明も TvLazyColumn と同一です。

whether the scrolling via the user gestures or accessibility actions is allowed. You can still scroll programmatically using the state even when it is disabled

これなら TvLazyColumn と同様に、userScrollEnabled でスクロールアニメーションにおける有効状態を設定できそうです。
実際にパラメーターを変えた際の挙動差を以下に示します。

userScrollEnabled == true userScrollEnabled == false

userScrollEnabled = false を設定してもフツーにスクロールしちゃってますね🤯

ここがハマりポイントで、LazyColumn の引数である userScrollEnabled ではスクロールアニメーションにおける有効状態を設定できません😱

テレビデバイスのフォーカス移動に伴うスクロールは BringIntoViewSpec が制御しているのですが、この BringIntoViewSpec は引数の userScrollEnabled を参照していない というのが原因です。

BringIntoViewSpec#calculateScrollDistance での制御

Tv Lazy Layout では、引数の userScrollEnabledTvBringIntoViewSpec のコンストラクタに渡されます[1:1]

@OptIn(ExperimentalFoundationApi::class)
private class TvBringIntoViewSpec(
    val pivotOffsets: PivotOffsets,
    val userScrollEnabled: Boolean
) : BringIntoViewSpec {

    // アイテムにフォーカスが当たった際に生じるスクロールのアニメーションを設定する
    override val scrollAnimationSpec: AnimationSpec<Float> = tween<Float>(...)

    // アイテムにフォーカスが当たった際に生じるスクロール量を返す
    override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float {
        if (!userScrollEnabled) return 0f // ここでスクロールアニメーションを無効化
        ...
    }

    override fun hashCode(): Int { ... }

    override fun equals(other: Any?): Boolean { ... }
}

そしてその後は calculateScrollDistance メソッドから userScrollEnabled が参照されるようになっています。

マイグレーションガイド にこのような実装が無いため見落としがちですが、BringIntoViewSpec インスタンスは userScrollEnabled も受け取れるようになっていると良さそうです👌

実際に BringIntoViewSpec#calculateScrollDistance で 0f を返却させるとスクロールアニメーションが無効化されました。

デフォルトの calculateScrollDistance calculateScrollDistance が 0f を return

まとめ

Tv Lazy Layout から Lazy Layout への移行にあたって、アニメーションロジックは少なからず変更が入っています。

今回の記事ではスクロールアニメーションにおける有効状態を変更する方法の差分について記載しました。

フォーカス移動に伴うスクロールは BringIntoViewSpec が担っているため、スクロールを無効化する場合のロジックも BringIntoViewSpec に持たせる必要があります。

実はもう一つハマったポイントがあり、記載しようと思ったのですが、サンプルアプリでは再現しませんでした。
(また再現できたら追記するか、新たに投稿するかで対応しようと思います)

このような、特定の状況限定のデグレも潜んでいる可能性があるので、引き続き多様なケースを想定しながらデバッグしていけると良さそうです。

脚注
  1. tv-foundation 1.0.0 alpha-10 ↩︎ ↩︎

  2. compose-foundation 1.7.5 ↩︎

Discussion