LazyLayoutで無限スクロールを実装する




# 前置き

以前は`HorizontalPager`で無限スクロールを擬似的に実装しましたが、
今回はより低水準な`LazyLayout`を利用してもう少しスマートに実装を試みます。

https://qiita.com/Seo-4d696b75/items/566beaf56df8da6421ca

# 完成品

:::note info
Compose 1.7.6 (BOM 2024.12.01) で動作確認しました
:::

https://github.com/Seo-4d696b75/compose-loop-pager

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/10ca36ac-ccd6-322a-a3eb-f4b28a782f0d.gif" width="300">


# 実装の解説

説明を簡単にするため、本記事では必要最低限な機能の実装のみ触れます。
完全な実装は GitHub を参照してください。

## LazyLayoutによる基本実装

まずは`LazyLayout`で表示する各ページの描画方法を定義する必要があります。重要なのは`LazyLayoutItemProvider`の２つのプロパティ・メソッドだけです。

- `itemCount`： ページの総数
- `Item(index,key)`： 指定された位置のページを描画する

```kotlin:ItemProvider.kt
@Composable
fun rememberItemProvider(
    itemCount: Int,
    content: @Composable (index: Int) -> Unit,
) = remember { ItemProvider(itemCount, content) }.apply {
    itemCountState.intValue = itemCount
    contentState.value = content
}

@OptIn(ExperimentalFoundationApi::class)
class ItemProvider internal constructor(
    itemCount: Int,
    content: @Composable (Int) -> Unit,
) : LazyLayoutItemProvider {

    internal val itemCountState = mutableIntStateOf(itemCount)
    internal val contentState = mutableStateOf(content)

    override val itemCount by itemCountState

    @Composable
    override fun Item(index: Int, key: Any) {
        contentState.value.invoke(index)
    }
}
```

次に`LazyLayout`ですが、各ページの描画有無・位置を決定する`measurePolicy`をどう実装するかがポイントです。ここでは概略だけ示して後続のセクションで詳説します。

```kotlin:HorizontalLoopPager.kt
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HorizontalLoopPager(
    itemCount: Int,
    aspectRatio: Float,
    modifier: Modifier = Modifier,
    content: @Composable (page: Int) -> Unit,
) {
    val itemProvider = rememberItemProvider(itemCount, content)

    LazyLayout(
        itemProvider = { itemProvider },
        prefetchState = null,
        measurePolicy = { constraints ->
            // 最大幅いっぱいに描画する
            val width = constraints.maxWidth
            // 固定の縦横比から高さを決定
            val height = (width / aspectRatio).roundToInt()

            // TODO 描画対象のページを計算する
            val indices = listOf(0)

            // 各ページの描画範囲
            val pageConstraints = Constraints(
                maxWidth = width,
                maxHeight = height,
            )
            // 各ページの描画サイズを計測
            val placeableMap = indices.associateWith {
                measure(it, pageConstraints)
            }
            // 描画
            layout(width, height) {
                placeableMap.forEach { (index, placeables) ->
                    // TODO 各ページ描画位置を計算する
                    val position = 0
                    placeables.forEach { placeable ->
                        placeable.placeRelative(position, 0)
                    }
                }
            }
        },
        modifier = modifier.clipToBounds(),
    )
}
```

## ScrollableStateの実装

Composeにおいてスクロール可能なコンポーネントの挙動を抽象化したのが `ScrollableState` インターフェイスであり、`Modifier.scrollable()`と合わせて利用します。今回のような無限スクロールでも何でも実現できます。

早速 `ScrollableState` を実装した独自の状態クラスを用意します。

```kotlin:LoopPagerState.kt
@Composable
fun rememberLoopPagerState(
    pageCount: Int,
    initialPage: Int = 0,
): LoopPagerState {
    return remember {
        LoopPagerState(
            pageCount = pageCount,
            initialPage = initialPage,
        )
    }.also {
        it.pageCount = pageCount
    }
}

@OptIn(ExperimentalFoundationApi::class)
@Stable
class LoopPagerState internal constructor(
    pageCount: Int,
    initialPage: Int,
) : ScrollableState {
    var pageCount by mutableIntStateOf(pageCount)
        internal set

    // 現在のページ位置
    // スクロール中は非整数になる = ページ単位で正規化されたスクロール位置
    var page: Float by mutableFloatStateOf(initialPage.toFloat())
        internal set

    // 描画対象のページを計算
    fun getVisiblePages(width: Int): Iterable<Int> {
        this.width = width
        // TODO
    }

    internal var width = 0

    // スクロール位置を更新する
    private fun performScroll(delta: Float): Float {
        val interval = width
        return if (interval > 0) {
            // スクロール量と符号が逆になる
            page -= (delta / interval)
            // 無限スクロールのため全てのスクロール量を消費する
            delta
        } else {
            0f
        }
    }

    private val scrollableState = ScrollableState(::performScroll)
    internal val interactionSource = MutableInteractionSource()

    override fun dispatchRawDelta(delta: Float): Float =
        scrollableState.dispatchRawDelta(delta)

    override suspend fun scroll(scrollPriority: MutatePriority, block: suspend ScrollScope.() -> Unit) =
        scrollableState.scroll(scrollPriority, block)

    // 無限スクロールのため常に両方向へスクロール可能
    override val canScrollForward = true
    override val canScrollBackward = true

    override val isScrollInProgress: Boolean by derivedStateOf {
        // TODO
    }
}
```

::: note ifo
`ScrollableState()`関数のデフォルト実装を利用できます
:::


`LazyLayout`に状態クラスを追加すると、

```diff_kotlin:HorizontalLoopPager.kt

 @OptIn(ExperimentalFoundationApi::class)
 @Composable
 fun HorizontalLoopPager(
-   itemCount: Int,
+   state: LoopPagerState,
    aspectRatio: Float,
    modifier: Modifier = Modifier,
    content: @Composable (page: Int) -> Unit,
 ) {
-   val itemProvider = rememberItemProvider(itemCount, content)
+   val itemProvider = rememberItemProvider(state.pageCount, content)

    LazyLayout(
         // ... 中略 ...
    
-        modifier = modifier.clipToBounds(),
+        modifier = modifier
+            .clipToBounds()
+            .scrollable(
+                state = state,
+                orientation = Orientation.Vertical,
+                interactionSource = state.interactionSource,
+            ),
    )
 }
```

## 描画対象のページを計算する

Pagerの描画幅 = 各ページの横幅のため、表示範囲は `[page, page + 1]`となります。見切れたページも表示する点に注意して、

```diff_kotlin:LoopPagerState.kt
    fun getVisiblePages(width: Int): Iterable<Int> {
        this.width = width
-       // TODO
+       val start = this.page
+       val end = start + 1
+       return floor(start).roundToInt()..floor(end).roundToInt()
    }
```

```diff_kotlin:HorizontalLoopPager.kt
    LazyLayout(
        itemProvider = { itemProvider },
        prefetchState = null,
        measurePolicy = { constraints ->
            // 最大幅いっぱいに描画する
            val width = constraints.maxWidth
            // 固定の縦横比から高さを決定
            val height = (width / aspectRatio).roundToInt()

            // 描画対象のページを計算する（負数も含む）
-           val indices = listOf(0);
+           val indices = state.getVisiblePages(width)

            // 各ページの描画範囲
            val pageConstraints = Constraints(
                maxWidth = width,
                maxHeight = height,
            )
            // 各ページの描画サイズを計測
            val placeableMap = indices.associateWith {
-               measure(it, pageConstraints)
+               // ページindexを正規化する
+               val index = it.mod(itemProvider.itemCount)
+               measure(index, pageConstraints)
            }
```

::: note warn
無限スクロールのため表示するページのインデックスが `[0, itemCount)`を外れる場合があります。`LazyLayoutItemProvider`へ渡す前に必ず正規化が必要です。
:::


## 各ページの描画位置を計算する

状態クラスが保持するスクロール位置を参照するだけです

```diff_kotlin:HorizontalLoopPager.kt
            // 描画
            layout(width, height) {
                placeableMap.forEach { (index, placeables) ->
                    // 各ページ描画位置を計算する
-                   val position = 0
+                   val positionInPages = index - state.page                   
+                   val position = (width * positionInPages).roundToInt()
                    placeables.forEach { placeable ->
                        placeable.placeRelative(position, 0)
                    }
                }
            }
```

## ページをスナップさせる

ここまでの実装でページの表示＆スクロールは可能ですが、スクロールする指を離してもページがスナップしません。

Composeにおいてスクロール時のスナップの挙動は `FlingBehavior`インターフェイスとして抽象化されていますが、全て自前で実装すると大変なので `snapFlingBehavior()` APIを利用するのが一般的です。スナップ位置を決定する`SnapLayoutInfoProvider`の実装クラスを用意するだけで、アニメーション処理など面倒な実装を丸投げできます。

:::note info
`SnapLayoutInfoProvider`で実装すべき２つの関数は以下のようなフローで使用されます

スクロール操作が終了したとき、

1. `calculateApproachOffset()`が返す位置まで減衰アニメーションを行う（スクロール速度が十分速い場合＝フリング操作時のみ呼ばれます）
2. `calculateSnapOffset()`が返す位置までスナップアニメーションを行う
:::

今回は簡単のため、どんなに速度が大きいフリング操作でも両隣のページだけにアニメーションする挙動（通常Pagerの`PagerSnapDistance.atMost(1)`に相当）を実装してみます。

```kotlin:LoopPagerSnapLayoutProvider.kt
internal class LoopPagerSnapLayoutProvider(
    private val state: LoopPagerState,
    private val velocityThreshold: Float,
) : SnapLayoutInfoProvider {
    override fun calculateSnapOffset(velocity: Float): Float {
        val currentPage = state.page
        val snapPage = if (velocity.absoluteValue < velocityThreshold) {
            // 現在のスクロール位置から最も近いスナップ位置
            currentPage.roundToInt()
        } else {
            // 隣のページ
            if (velocity > 0) {
                floor(currentPage).roundToInt()
            } else {
                ceil(currentPage).roundToInt()
            }
        }
        val interval = state.width
        return if (interval > 0) {
            // 符号の逆転に注意
            -(snapPage - currentPage) * interval
        } else {
            0f
        }
    }

    override fun calculateApproachOffset(velocity: Float, decayOffset: Float): Float {
        // 両隣のページを超えてスナップしないため減衰アニメーション無し
        return 0f
    }
}
```

用意できた`FlingBehavior`の実装クラスは `Modifier.scrollable()`に指定します。

```diff_kotlin:HorizontalLoopPager.kt
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HorizontalLoopPager(
    state: LoopPagerState,
    aspectRatio: Float,
    modifier: Modifier = Modifier,
    content: @Composable (page: Int) -> Unit,
) {
    val itemProvider = rememberItemProvider(state.pageCount, content)
    
+   val density = LocalDensity.current
+   val velocityThreshold = with(density) { 400.dp.toPx() }
+   val flingBehavior = remember(state, velocityThreshold) {
+       snapFlingBehavior(
+           snapLayoutInfoProvider = LoopPagerSnapLayoutProvider(state, velocityThreshold),
+           decayAnimationSpec = exponentialDecay(),
+           snapAnimationSpec = spring(),
+       )
+   }
    
    LazyLayout(
         // ... 中略 ...
    
         modifier = modifier
             .clipToBounds()
             .scrollable(
                 state = state,
                 orientation = Orientation.Vertical,
                 interactionSource = state.interactionSource,
+                flingBehavior = flingBehavior,
             ),
    )
 }
```