【Jetpack Compose】無限にスクロールするPagerを作る【Android】

こんにちは、 id:numanuma08 です。Jetpack Composeで無限にスクロールするPagaerを作ります。

こういうやつです。

表示したいコンテンツの数に関わらずPagerのページ数をInt.MAX_VALUEとし、初期位置をInt.MAX_VALUE / 2とします。表示するコンテンツのindexはHorizontalViewPagerのパラメータに取る無名関数のパラメーターで与えられるindexを使って実際のコンテンツのindexを計算して取得します。

val initialIndex = Int.MAX_VALUE / 2
val pagerState = rememberPagerState(
    initialPage = initialIndex,
    pageCount = { Int.MAX_VALUE },
)
// 表示するコンテンツ。Compose関数のパラメータなどで渡されることを想定
val itemCount = 3
HorizontalPager(
    state = pagerState,
    pageSpacing = 16.dp,
    pageSize = PageSize.Fixed(200.dp),
    // 常に左右に隣のコンテンツが見切れているようにする
    snapPosition = SnapPosition.Center,
) { page ->
    val index = page % itemCount
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(200.dp)
            .background(Color.Red),
        contentAlignment = Alignment.Center,
    ) {
        Text("Page: $index")
    }
}

いちおう理論上Int.MAX_VALUE / 2回同じ方向にスクロールされるとページの最後に到達してしまうので対策をしておきます。

val scope = rememberCoroutineScope()
// Int.MAX_VALUE / 2 回スクロールされてもスクロールが終了しないようにするため
// スクロールごとにページ位置を Int.MAX_VALUE / 2 付近に戻す
// ユーザーのスクロール中にLaunchedEffectが実行されるため、別のコルーチンスコープを実行して、
// スクロールが中途半端にcancelされないようにする
LaunchedEffect(key1 = pagerState.currentPage) {
    scope.launch {
        if (!pagerState.isScrollInProgress) {
            pagerState.animateScrollToPage(
                page = (Int.MAX_VALUE / 2) + pagerState.currentPage % itemCount,
            )
        }
    }
}

以上を踏まえたComposable関数の全体が以下です。

@Composable
fun InfinityPager(modifier: Modifier = Modifier) {
    val initialIndex = Int.MAX_VALUE / 2
    val pagerState = rememberPagerState(
        initialPage = initialIndex,
        pageCount = { Int.MAX_VALUE },
    )
    val itemCount = 3
    val scope = rememberCoroutineScope()
    // Int.MAX_VALUE / 2 回スクロールされてもスクロールが終了しないようにするため
    // スクロールごとにページ位置を Int.MAX_VALUE / 2 付近に戻す
    // ユーザーのスクロール中にLaunchedEffectが実行されるため、別のコルーチンスコープを実行して、
    // スクロールが中途半端にcancelされないようにする
    LaunchedEffect(key1 = pagerState.currentPage) {
        scope.launch {
            if (!pagerState.isScrollInProgress) {
                pagerState.animateScrollToPage(
                    page = (Int.MAX_VALUE / 2) + pagerState.currentPage % itemCount,
                )
            }
        }
    }
    Column(modifier = modifier) {
        HorizontalPager(
            state = pagerState,
            pageSpacing = 16.dp,
            pageSize = PageSize.Fixed(200.dp),
            // 常に左右に隣のコンテンツが見切れているようにする
            snapPosition = SnapPosition.Center,
        ) { page ->
            val index = page % itemCount
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(200.dp)
                    .background(Color.Red),
                contentAlignment = Alignment.Center,
            ) {
                Text("Page: $index")
            }
        }
        Spacer(modifier = Modifier.height(16.dp))
        // インジケーター部分の実装
        Row(
            modifier = Modifier
                .align(Alignment.CenterHorizontally),
            horizontalArrangement = Arrangement.spacedBy(6.dp),
        ) {
            repeat(itemCount) { index ->
                val selected = index == pagerState.currentPage % itemCount
                Box(
                    modifier = Modifier
                        .size(6.dp)
                        .clip(RoundedCornerShape(16.dp))
                        .background(
                            color = if (selected) {
                                Color(0xFF000000)
                            } else {
                                Color(0xFFB2B2B2)
                            },
                            shape = RoundedCornerShape(16.dp),
                        ),
                )
            }
        }
    }
}