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

こんな感じで時間が経つとゲージが溜まって勝手にスクロールするやつです。カルーセルは無限にスクロールできるものとします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/53293/c974d083-7ab2-91d0-a923-a9519de81b0f.png)

コードを先に貼っておきます。
```kotlin
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,
    )
}
```
## 解説

### 無限にスクロールする
```kotlin
private const val PAGE_COUNT = Int.MAX_VALUE
private const val INITIAL_PAGE = PAGE_COUNT / 2
```

ページの総数に途方もなくデカい値に設定し、実際ページ数とのmodで表示すべきページを計算します。これはよくあるテクニックなので詳細は省略します。

### Pagerの外からPagerの状態にアクセスする
```kotlin
val pagerState = rememberPagerState(initialPage = INITIAL_PAGE)
HorizontalPager(state = pagerState, ...) { ... }
```
pagerStateを設定することで`pagerState.currentPage`のようにアクセスできます。

### アニメーション
```kotlin
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)`を実行します。

https://developer.android.com/jetpack/compose/animation?hl=ja#animatable


## コード
https://github.com/haru067/playground/blob/main/app/src/main/java/com/haru067/playground/components/CarouselExample.kt