前置き
以前はHorizontalPager
で無限スクロールを擬似的に実装しましたが、
今回はより低水準なLazyLayout
を利用してもう少しスマートに実装を試みます。
完成品
Compose 1.7.6 (BOM 2024.12.01) で動作確認しました

実装の解説
説明を簡単にするため、本記事では必要最低限な機能の実装のみ触れます。
完全な実装は GitHub を参照してください。
LazyLayoutによる基本実装
まずはLazyLayout
で表示する各ページの描画方法を定義する必要があります。重要なのはLazyLayoutItemProvider
の2つのプロパティ・メソッドだけです。
-
itemCount
: ページの総数 -
Item(index,key)
: 指定された位置のページを描画する
@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
をどう実装するかがポイントです。ここでは概略だけ示して後続のセクションで詳説します。
@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
を実装した独自の状態クラスを用意します。
@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
に状態クラスを追加すると、
@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]
となります。見切れたページも表示する点に注意して、
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()
}
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
へ渡す前に必ず正規化が必要です。
各ページの描画位置を計算する
状態クラスが保持するスクロール位置を参照するだけです
// 描画
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つの関数は以下のようなフローで使用されます
スクロール操作が終了したとき、
-
calculateApproachOffset()
が返す位置まで減衰アニメーションを行う(スクロール速度が十分速い場合=フリング操作時のみ呼ばれます) -
calculateSnapOffset()
が返す位置までスナップアニメーションを行う
今回は簡単のため、どんなに速度が大きいフリング操作でも両隣のページだけにアニメーションする挙動(通常PagerのPagerSnapDistance.atMost(1)
に相当)を実装してみます。
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()
に指定します。
@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,
),
)
}