3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-04-02

前置き

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

完成品

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

実装の解説

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

LazyLayoutによる基本実装

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

  • itemCount: ページの総数
  • Item(index,key): 指定された位置のページを描画する
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をどう実装するかがポイントです。ここでは概略だけ示して後続のセクションで詳説します。

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 を実装した独自の状態クラスを用意します。

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
    }
}

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

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

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]となります。見切れたページも表示する点に注意して、

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()
    }
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)
            }

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

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

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

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の実装クラスを用意するだけで、アニメーション処理など面倒な実装を丸投げできます。

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

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

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

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

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()に指定します。

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,
             ),
    )
 }
3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?