前置き
以前はHorizontalPager
で無限スクロールを擬似的に実装しましたが、
今回はより低水準なLazyLayout
を利用してもう少しスマートに実装を試みます。
完成品
実装の解説
LazyLayoutによる基本実装
まずはLazyLayout
で表示する各ページの描画方法を定義する必要があります。重要なのはLazyLayoutItemProvider
の2つのプロパティ・メソッドだけです。
-
itemCount
: ページの総数 -
Item(index,key)
: 指定された位置のページを描画する
@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
をどう実装するかがポイントです。ここでは概略だけ示して後続のセクションで詳説します。
@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
を保持する独自の状態クラスを用意します。
@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
に状態クラスを追加すると、
@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]となります。
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
}
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を参照するだけです
// 描画
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
に対応させることも可能です。実装の詳細はコードを直接参照してください。