こんな感じで時間が経つとゲージが溜まって勝手にスクロールするやつです。カルーセルは無限にスクロールできるものとします。
コードを先に貼っておきます。
private const val PAGE_COUNT = Int.MAX_VALUE
private const val INITIAL_PAGE = PAGE_COUNT / 2
private val pageColors = listOf(
Color.Red,
Color.Green,
Color.Blue,
)
private fun getActualPage(page: Int, actualPageCount: Int): Int {
val diff = (page - INITIAL_PAGE) % actualPageCount
return if (diff < 0) actualPageCount + diff else diff
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CarouselExample(modifier: Modifier = Modifier) {
Box(modifier) {
val pagerState = rememberPagerState(initialPage = INITIAL_PAGE)
val actualPageCount = pageColors.size
HorizontalPager(
state = pagerState,
pageCount = if (actualPageCount > 0) PAGE_COUNT else 0,
modifier = Modifier.fillMaxSize(),
) { page ->
val actualPage = getActualPage(page = page, actualPageCount = actualPageCount)
val color = pageColors[actualPage]
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.background(color)
.fillMaxSize()
) {
Text("Page $actualPage")
}
}
if (actualPageCount > 0) {
val actualSettledPage = getActualPage(page = pagerState.settledPage, actualPageCount = actualPageCount)
HorizontalPagerIndicator(
currentPage = actualSettledPage,
pageCount = actualPageCount,
scrollToPage = { actualNextPage ->
val distance = actualNextPage - actualSettledPage
pagerState.animateScrollToPage(pagerState.settledPage + distance)
},
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
)
}
}
}
@Composable
fun HorizontalPagerIndicator(
modifier: Modifier = Modifier,
currentPage: Int,
pageCount: Int,
scrollToPage: suspend (Int) -> Unit,
) {
Row(modifier) {
for (page in 0 until pageCount) {
IndicatorItem(
animate = page == currentPage,
onAnimationFinished = { scrollToPage(page + 1) },
onClick = { scrollToPage(page) },
modifier = Modifier
.weight(1f)
.padding(4.dp),
)
}
}
}
@Composable
private fun IndicatorItem(
progress: Float?,
onClick: suspend () -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
Box(modifier.clickable { scope.launch { onClick() } }) {
// Indicator line
Box(
Modifier
.fillMaxWidth()
.height(4.dp)
.background(Color.White.copy(alpha = 0.5f))
)
if (progress != null) {
// Progress line
Box(
Modifier
.fillMaxWidth(progress)
.height(4.dp)
.background(Color.White)
)
}
}
}
@Composable
private fun IndicatorItem(
animate: Boolean,
onAnimationFinished: suspend () -> Unit,
onClick: suspend () -> Unit,
modifier: Modifier = Modifier,
) {
val progress = remember { Animatable(0f) }
LaunchedEffect(animate) {
if (animate) {
val result = progress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 5000, easing = LinearEasing),
)
if (result.endReason == AnimationEndReason.Finished) {
onAnimationFinished()
}
} else {
progress.snapTo(0f)
}
}
IndicatorItem(
progress = progress.value,
onClick = onClick,
modifier = modifier,
)
}
解説
無限にスクロールする
private const val PAGE_COUNT = Int.MAX_VALUE
private const val INITIAL_PAGE = PAGE_COUNT / 2
ページの総数に途方もなくデカい値に設定し、実際ページ数とのmodで表示すべきページを計算します。これはよくあるテクニックなので詳細は省略します。
Pagerの外からPagerの状態にアクセスする
val pagerState = rememberPagerState(initialPage = INITIAL_PAGE)
HorizontalPager(state = pagerState, ...) { ... }
pagerStateを設定することでpagerState.currentPage
のようにアクセスできます。
アニメーション
val progress = remember { Animatable(0f) }
LaunchedEffect(animate) {
if (animate) {
val result = progress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 5000, easing = LinearEasing),
)
if (result.endReason == AnimationEndReason.Finished) {
onAnimationFinished()
}
} else {
progress.snapTo(0f)
}
}
animate*AsState
で行けるかな?と思ったのですが、スワイプやタップによる任意のタイミングで開始/停止/キャンセルすることを考慮するとAnimatable
で実装するのが良さそうでした。animationSpec
で自動遷移の時間をセットし、endReason
でアニメーション完了を取得します。また、途中でアニメーションを止める場合にはsnapTo(0f)
を実行します。
コード