0
0

More than 1 year has passed since last update.

【Jetpack Compose】時間経過に合わせてスクロールするカルーセルにアニメーション付きのインジケータを追加する方法

Posted at

こんな感じで時間が経つとゲージが溜まって勝手にスクロールするやつです。カルーセルは無限にスクロールできるものとします。
image.png

コードを先に貼っておきます。

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)を実行します。

コード

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