LoginSignup
1
1

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

Posted at

前置き

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

完成品

実装の解説

LazyLayoutによる基本実装

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

  • itemCount: ページの総数
  • Item(index,key): 指定された位置のページを描画する
ItemProvider.kt
@Composable
fun <T> rememberItemProvider(
    items: List<T>,
    content: @Composable (item: T, index: Int) -> Unit,
) = remember { ItemProvider(items, content) }.apply {
    update(items, content)
}

@OptIn(ExperimentalFoundationApi::class)
class ItemProvider<T> internal constructor(
    initialItems: List<T>,
    initialContent: @Composable (item: T, index: Int) -> Unit,
) : LazyLayoutItemProvider {

    private val itemsState = mutableStateOf(initialItems)
    private val contentState = mutableStateOf(initialContent)

    fun update(items: List<T>, content: @Composable (T, Int) -> Unit) {
        itemsState.value = items
        contentState.value = content
    }

    override val itemCount
        get() = itemsState.value.size

    @Composable
    override fun Item(index: Int, key: Any) {
        itemsState.value.getOrNull(index)?.let {
            contentState.value.invoke(it, index)
        }
    }
}

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

HorizontalLoopPager.kt
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <T> HorizontalLoopPager(
    items: List<T>,
    aspectRatio: Float,
    modifier: Modifier = Modifier,
    content: @Composable (item: T, page: Int) -> Unit,
) {
    val itemProvider = rememberItemProvider(items, 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(),
    )
}

AnchoredDraggableState

LazyLayoutとは別にスクロール位置の状態管理が必要です。加えてスワイプ特有のユーザー操作・アニメーションにも対応させたいのでAnchoredDraggableStateを使います。

  • ドラッグ操作
  • ページ間の中途半端な位置でドラッグ操作を終了したとき、近傍のページ位置へ自動スクロール
  • fling 操作(ドラッグ量が小さくとも指を動かす速度が大きい場合に次のページに進む)

(従来のSwipeableより多機能・柔軟性が高いです)

公式ドキュメントの通り、AnchoredDraggableStateを保持する独自の状態クラスを用意します。

LoopPagerState.kt
@Composable
fun rememberLoopPagerState(): LoopPagerState {
    val density = LocalDensity.current
    return remember {
        LoopPagerState(
            // ページ幅の半分以上ドラッグしたら次のページに自動アニメーションする
            positionalThreshold = { it * 0.5f },
            // fling操作を検知する速度閾値 (pixel/sec)
            velocityThreshold = { with(density) { 125.dp } },
            // アニメーションの指定
            animationSpec = spring(),
        )
    }
}

@OptIn(ExperimentalFoundationApi::class)
@Stable
class LoopPagerState internal constructor(
    positionalThreshold: (totalDistance: Float) -> Float,
    velocityThreshold: () -> Float,
    animationSpec: AnimationSpec<Float>,
) {
    internal val anchoredDraggableState = AnchoredDraggableState(
        initialValue = 0,
        positionalThreshold = positionalThreshold,
        velocityThreshold = velocityThreshold,
        animationSpec = animationSpec,
    )

    // 描画位置のoffset
    val offset: Int
        get() = anchoredDraggableState.requireOffset().roundToInt()

    // 現在表示中のページ(負数の場合あり)
    val currentPage: Int
        get() = anchoredDraggableState.currentValue

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

    // 各ページのスクロール位置を更新
    fun updateAnchors(width: Int) {
        anchoredDraggableState.updateAnchors(
            DraggableAnchors {
                // 現在のページと両隣
                listOf(
                    currentPage - 1,
                    currentPage,
                    currentPage + 1,
                ).forEach { index ->
                    // 描画位置のoffsetとスクロール量は符号が逆!
                    index at -index * width.toFloat()
                }
            }
        )
    }
}

AnchoredDraggableState.updateAnchors()

Swipeableとは異なり、AnchoredDraggableStateはスクロール位置(アンカー)を動的に制御できるため無限スクロールには好都合です

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

HorizontalLoopPager.kt

 @OptIn(ExperimentalFoundationApi::class)
 @Composable
 fun <T> HorizontalLoopPager(
    items: List<T>,
    aspectRatio: Float,
    modifier: Modifier = Modifier,
+   state: LoopPagerState = rememberLoopPagerState(),
    content: @Composable (item: T, page: Int) -> Unit,
 ) {
    val itemProvider = rememberItemProvider(items, content)

    LazyLayout(
    
    
-        modifier = modifier.clipToBounds(),
+        modifier = modifier
+            .clipToBounds()
+            .anchoredDraggable(
+                state = state.anchoredDraggableState,
+                orientation = Orientation.Horizontal,
+            ),
    )
 }

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

描画位置offsetとスクロール量は符号が逆なので、pixel単位での描画範囲は [-offset, -offset + width]となります。

LoopPagerState.kt
    fun getVisiblePages(width: Int): Iterable<Int> {
-       // TODO
+       val start = floor(-offset.toFloat() / width).toInt()
+       val end = floor((-offset + width).toFloat() / width).toInt()
+       return start..end
    }
HorizontalLoopPager.kt
    LazyLayout(
        itemProvider = { itemProvider },
        prefetchState = null,
        measurePolicy = { constraints ->
            // 最大幅いっぱいに描画する
            val width = constraints.maxWidth
            // 固定の縦横比から高さを決定
            val height = (width / aspectRatio).roundToInt()

            // 描画対象のページを計算する(負数も含む)
-           val indices = listOf(0);
+           state.updateAnchors(width)
+           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)
            }

AnchoredDraggableState.requireOffset()

offsetが初期化前だと例外を投げるため、getVisiblePages()でoffsetを参照する前にupdateAnchors()でoffsetを決定させる必要があります。

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

状態クラスのoffsetを参照するだけです

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

追加の機能実装

本記事の範囲外にはなりますが、HorizontalPagerと同様のcontentPaddingに対応させることも可能です。実装の詳細はコードを直接参照してください。

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