👋

【Jetpack Compose】脱 Tv Lazy Layout --- 移行前後の差分編

2024/11/16に公開

tv-foundation 1.0.0-alpha11 のリリース

2024年07月10日に Android TV 向けのライブラリである tv-foundation 1.0.0-alpha11 がリリースされました。

リリースノート に記載されている内容は以下の通りです。[1]

Version 1.0.0-alpha11

androidx.tv:tv-foundation:1.0.0-alpha11 is released. Version 1.0.0-alpha11 contains these commits.

API Changes

  • Tv Lazy Layouts have been deprecated from tv-foundation library. Refer to this ticket to learn how to migrate away from the tv lazy layouts. (I0855f, b/332674072)
  • PlatformImeOptions is now a concrete class instead of an interface. (If40a4)

Tv Lazy Layout が非推奨になるから、Lazy Layout を使うようにしてね とのことです。

私の開発している Android TV 向けアプリも先日 Tv Lazy Layout から Lazy Layout への移行対応を行いました。
基本的には マイグレーションガイド に従っていけばよかったのですが、いくつかハマったポイントがあったので記事にまとめていこうと思います。

内容が少し多くなりそうなので、いくつかの記事に分けて記載していこうと思います。

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

本記事では、私の経験したハマりポイントを記載する前段階として、改めて移行前後でどのような差分が入るのかをまとめます。

Tv Lazy Layout と Lazy Layout におけるスクロール処理

Tv Lazy Layout から Lazy Layout へ移行する際に注意すべき差分は「フォーカスに伴うスクロールロジック」くらいです。
マイグレーションガイド にもフォーカスに伴うスクロールロジックをカスタマイズしていなければ単に Tv のプレフィックスを取るだけで良いと記載されています。

【Tv Lazy Layout】 PivotOffsets

PivotOffsetsアイテムがフォーカスされた際にどれだけスクロールさせるか を決めるためのクラスです。

PivotOffsets.kt (tv-foundation 1.0.0-alpha10)
/**
 * Holds the offsets needed for scrolling-with-offset.
 *
 * @property parentFraction defines the offset of the starting edge of the child
 * element from the starting edge of the parent element. This value should be between 0 and 1.
 * @property childFraction defines the offset of the starting edge of the child from
 * the pivot defined by parentFraction. This value should be between 0 and 1.
 */
@Immutable
class PivotOffsets constructor(
    @FloatRange(
        from = 0.0,
        to = 1.0,
        fromInclusive = true,
        toInclusive = true
    ) val parentFraction: Float = 0.3f,
    @FloatRange(
        from = 0.0,
        to = 1.0,
        fromInclusive = true,
        toInclusive = true
    ) val childFraction: Float = 0f,
) {
    ....
}

PivotOffsetsTvLazyColumn 特有の引数であり、LazyColumn では引数で受け取れません。[2]

デフォルト引数では、parentFraction = 0.3f, childFraction = 0f が設定されます。
これは以下画像のように フォーカスされた Component の最上部がリストの高さの 30% 地点に揃えられるようにスクロールさせる ということを意味しています。

スクロールの基準位置 実際のスクロールアニメーション

前述の通り、デフォルト引数 (parentFraction = 0.3f, childFraction = 0f) のままであれば、Lazy Layout への移行時に必要になる対応は特にありません。
しかし、これらの値を変更している場合は後述の CompositionLocal で BringIntoViewSpec を配布する実装を行う必要があります。

【Lazy Layout】 BringIntoViewSpec

マイグレーションガイド に突如出現しました。

Lazy Layout では PivotOffset を引数に受け取らないため、BringIntoViewSpec インスタンスを CompositionLocal で配布する方式になるようです。

呼び出し元の差分としては以下のようになるイメージです。
(createBringIntoViewSpec に関しては マイグレーションガイド を参考にして実装してください)

-TvLazyColumn(
-  pivotOffsets = PivotOffsets(parentFraction = 1f),
-) {
-  ....
-}
+val bringIntoViewSpec = remember { createBringIntoViewSpec(parentFraction = 1f) }
+
+CompositionLocalProvider(
+  LocalBringIntoViewSpec provides bringIntoViewSpec
+) {
+  LazyColumn {
+    ....
+  }
+}

馴染みのない BringIntoViewSpec の登場は少し戸惑いますね🙄

Tv Lazy Layout の実装詳細を少し追ってみると、実は引数に渡した PivotOffsetsBringIntoViewSpec インスタンスの生成に利用されていたことが分かります。

ScrollableWithPivot.kt (tv-foundation 1.0.0-alpha10)
@OptIn(ExperimentalFoundationApi::class)
@ExperimentalTvFoundationApi
fun Modifier.scrollableWithPivot(
    state: ScrollableState,
    orientation: Orientation,
    pivotOffsets: PivotOffsets,
    enabled: Boolean = true,
    reverseDirection: Boolean = false
): Modifier = this then Modifier.inspectable(debugInspectorInfo { .... }) {
    Modifier.scrollable(
        state = state,
        orientation = orientation,
        enabled = enabled,
        reverseDirection = reverseDirection,
        overscrollEffect = null,
        bringIntoViewSpec = TvBringIntoViewSpec(pivotOffsets, enabled)
    )
}

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

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

    // アイテムにフォーカスが当たった際に生じるスクロール量を返す
    override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float {
        if (!userScrollEnabled) return 0f
        val leadingEdgeOfItemRequestingFocus = offset
        val trailingEdgeOfItemRequestingFocus = offset + size

        val sizeOfItemRequestingFocus =
            abs(trailingEdgeOfItemRequestingFocus - leadingEdgeOfItemRequestingFocus)
        val childSmallerThanParent = sizeOfItemRequestingFocus <= containerSize
        val initialTargetForLeadingEdge =
            pivotOffsets.parentFraction * containerSize -
                (pivotOffsets.childFraction * sizeOfItemRequestingFocus)
        val spaceAvailableToShowItem = containerSize - initialTargetForLeadingEdge

        val targetForLeadingEdge =
            if (childSmallerThanParent && spaceAvailableToShowItem < sizeOfItemRequestingFocus) {
                containerSize - sizeOfItemRequestingFocus
            } else {
                initialTargetForLeadingEdge
            }

        return leadingEdgeOfItemRequestingFocus - targetForLeadingEdge
    }

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

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

つまり、公開されているインタフェースは PivotOffsets から BringIntoViewSpec に変更されたものの、内部のスクロールロジックは Tv Lazy Layout と Lazy Layout で同じようです。

スクロール処理の差分まとめ

以上の差分を改めてまとめてみます。

  • Before
    • PivotOffets を Tv Lazy Layout の引数に渡す
      • Tv Lazy Layout の内部実装によって BringIntoViewSpec が生成される
      • スクロール処理を行う ModifierNode は ModifierNodeElement から渡された BringIntoViewSpec を参照しながら処理を実行する
  • After
    • Lazy Layout の実行側で BringIntoViewSpec を生成し、配布する
      • CompositionLocal を利用して配布を行う
      • スクロール処理を行う ModifierNode は CompositionLocal にアクセスしながら処理を実行する

まとめ

tv-foundation 1.0.0-alpha11 にて Tv Lazy Layout が非推奨になり、Lazy Layout への移行が必要になりました。

Tv Lazy Layout のデフォルト引数で受け取っている PivotOffsets を使用している場合は単に Tv のプレフィックスを取るだけで、他に必要な作業はありません。

PivotOffsets にデフォルト引数とは異なる値を設定している場合は「CompositionLocal を利用して BringIntoViewSpec を配布させる」という対応が必要になります。

以降の記事で、「Tv Lazy Layout から Lazy Layout への移行で私が遭遇したハマりポイント」に関して記載していこうと思います。

脚注
  1. 2024/11/16アクセス ↩︎

  2. compose-foundation 1.7.5時点 ↩︎

Discussion