【Kotlin】Jetpack Composeで無限スクロール風とスワイプ検知

# はじめに
この記事は [レコチョク Advent Calendar 2024](https://qiita.com/advent-calendar/2024/recochoku) の11日目の記事となります。 

こんにちは、Androidアプリ開発Gの深沢です。この間韓国に行ったときにそこら中で[ROSÉ & Bruno Mars - APT.](https://recochoku.jp/song/S1025629421/single)が流れていた影響でずっとサビが頭の中で流れ続けています。

最近この曲ともう1つ自分の頭をよく占領しているものがあります。みなさん、Pokémon Trading Card Game Pocket(通称ポケポケ)をご存知でしょうか？簡単に説明しますとポケモンカードがアプリでコレクション&対戦ができるようになったという2024年11月現在(少なくとも私の周りでは)非常に流行っているスマホアプリになります。
こちら、アプリ上にある複数のパックから自分で好きなものを剥けるという非常にドキドキ・ワクワクする仕組みになっているのですが、通常は12時間に1回しかパックが開封できません。
そこで、自分で疑似アプリを作成し、自身のAndroid技術の勉強と共に好きなときに疑似パックを剥けるようにしました。

# 完成品

![ad_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2969454/8d7e385e-fea4-5af0-320a-2cf532c4c063.gif)



スワイプで好きなCardを選び、スワイプするとレアリティを示すマークが表示されます。こちらのマークはネットで調べた情報を元に表示される確率を少し操作しています。

# いざ、作成
## Cardをスクロールできるようにする
まず、ページ遷移前の「なにが出るかな？」というCardが並んでいる画面を作成します。
こちらは[Accompanist無しで無限スクロールを実装する](https://qiita.com/Seo-4d696b75/items/566beaf56df8da6421ca)を参考にさせていただきました。

```kotlin
@Composable
fun HorizontalLoopPager(
    navController: NavController,
    count: Int,
    modifier: Modifier = Modifier,
) {
    val state = rememberPagerState(
        initialPage = count, // 初期位置
        pageCount = { count * 3 }, // 総ページ数
    )

    LaunchedEffect(state) {
        snapshotFlow { state.settledPage to state.isScrollInProgress }
            .filter { !it.second }  // state.isScrollInProgressがfalseつまりスクロールしていないときだけ処理を行う
            .map { it.first } // state.settledPageつまり選択されているページを取得
            .collectLatest {
                when {
                    it < count -> it + count // スクロール位置がはじめの方のときはcount分足して中央に近づける
                    it >= count * 2 -> it - count // スクロール位置が最後の方のときはcount分引いて中央に近づける
                    else -> null
                }?.let { idx ->
                    launch {
                        state.scrollToPage(idx)
                    }
                }
            }
    }
    HorizontalPager(
        state = state,
        contentPadding = PaddingValues(horizontal = 64.dp),
        modifier = modifier.fillMaxWidth(),
        pageSpacing = 32.dp,
    ) {
        val index = it.mod(count)
        val isCentralPage = index == state.currentPage.mod(count)

        val cardModifier = if (isCentralPage) {
            Modifier
                .aspectRatio(1f)
                .scale(1.1f)
                .clickable {
                    navController.navigate("result")
                }
        } else {
            Modifier.aspectRatio(1f)
        }

        Card(
            modifier = cardModifier,
            colors = CardDefaults.cardColors(
                containerColor = if (isCentralPage) Color.Gray else Color.LightGray
            )
        ) {
            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center,
            ) {
                Text(
                    text = "何が出るかな?",
                    style = MaterialTheme.typography.headlineLarge,
                    color = Color.White,
                )
            }
        }
    }
}
```

選択されているページ番号の値によってcountの中央部分になるように計算を行い、その計算結果の場所までアニメーションなしでジャンプさせることにより永遠にスクロールできるように見せかけています。
`count`の値や`pageCount `の値は何でも良いですが、中央のCardの両端にも永遠にCardが表示されるようにしたかったため、初期位置を全体の1/3のところにしました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2969454/0db9935b-397f-1916-cdad-ef35ba4a295b.png" width="200"/>

*pageCount=countにするとページ数と初期位置が同じなので、最後のCardのように見えてしまう*

また、選択されているCardがわかりやすいように、Cardの背景色とサイズに変化をもたせました。
中央のCardをタップすると次の画面に遷移します。今回はnavigationを用いて画面遷移を行いました。

## スワイプで結果を表示
ページ遷移後スワイプで結果を表示します。
```kotlin
@Composable
fun Result() {
    val scope = rememberCoroutineScope()
    val result = getRandomResult()
    var currentResult by remember { mutableStateOf("スワイプしてください") }
    var isSwiped by remember { mutableStateOf(false) }

    var totalDragDistance = 0f
    val thresholdDistance = 100.dp

    Box(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(isSwiped) {
                if (!isSwiped) {
                    detectHorizontalDragGestures(
                        onHorizontalDrag = { change, dragAmount ->
                            totalDragDistance += dragAmount
                            if (totalDragDistance.absoluteValue > thresholdDistance.toPx()) {
                                scope.launch {
                                    currentResult = result
                                    isSwiped = true
                                }
                            }
                        },
                        onDragEnd = {

                        }
                    )
                }
            },
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = currentResult,
            fontSize = 32.sp,
        )
    }
}
```
`detectHorizontalDragGestures()`を用いてスワイプを検知し、`thresholdDistance.toPx()`というしきい値以上横方向にスワイプしたときに結果を表示しています。
`isSwiped`というフラグをもたせることで、何回もスワイプできないようにしています。


![ad_2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2969454/989fcc73-fec9-d45f-6e1c-8440298c6108.gif)
*isSwipeを使用しない場合何度もスワイプできてしまう*


続いてレアリティの調整を行います。
今回表示されるマークと出現確率は以下のようにしました。

|結果|出現確率
|---|---|
|♢|50%|
|♢♢|20%|
|♢♢♢|10%|
|♢♢♢♢|6.664%|
|☆|10.288%|
|☆☆|2.0%|
|☆☆☆|0.888%|
|👑|0.160%|

そして出現確率に応じてマークを返す`getRandomResult()`という関数を作成し、`Result()`内で使用しました。

```kotlin
private fun getRandomResult(): String {
    val items = listOf(
        "♢" to 50.0,
        "♢♢" to 20.0,
        "♢♢♢" to 10.0,
        "♢♢♢♢" to 6.664,
        "☆" to 10.288,
        "☆☆" to 2.0,
        "☆☆☆" to 0.888,
        "👑" to 0.160
    )
    val cumulativeProbabilities = items.foldIndexed(listOf<Pair<String, Double>>()) { _, acc, (item, probability) ->
        val cumulativeProbability = if (acc.isEmpty()) probability else acc.last().second + probability
        acc + (item to cumulativeProbability)
    }

    // 0から100のランダムな値を生成
    val randomValue = Random.nextDouble(0.0, 100.0)

    return cumulativeProbabilities.first { randomValue <= it.second }.first

}
```
まず、出現確率をもとにitemsというリストを作成します。
次に`cumulativeProbabilities`で出現確率に合わせた数字を割り当てていきます。0~50は♢に、50~70は♢♢に...というようなイメージです。
そして`randomValue`でランダムに出した値が`cumulativeProbabilities`のどのレアリティに対応しているかで返り値を決めています。

このようにすることで完成品のようなアプリを作成しました。

以下がコード全体です。(import文は割愛しています)

```kotlin
class MainActivity : ComponentActivity() {
    @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            HogehogeTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                       AppNavigation()
                }
            }
        }
    }
}

@Composable
fun AppNavigation() {
    val navController = rememberNavController()

    NavHost(navController, startDestination = "pagerSection") {
        composable("pagerSection") { PagerSection(navController = navController) }
        composable("result") { Result() }
    }
}

@Composable
fun PagerSection(
    modifier: Modifier = Modifier,
    navController: NavController,
) {
    Box(
        modifier = modifier
            .fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        // スクロールできるように10に設定
        HorizontalLoopPager(
            navController = navController,
            count = 10
        )
    }
}

@Composable
fun HorizontalLoopPager(
    navController: NavController,
    count: Int,
    modifier: Modifier = Modifier,
) {
    val state = rememberPagerState(
        initialPage = count,
        pageCount = { count * 3 },
    )

    LaunchedEffect(state) {
        snapshotFlow { state.settledPage to state.isScrollInProgress }
            .filter { !it.second } 
            .map { it.first }
            .collectLatest {
                when {
                    it < count -> it + count
                    it >= count * 2 -> it - count
                    else -> null
                }?.let { idx ->
                    launch {
                        state.scrollToPage(idx)
                    }
                }
            }
    }
    HorizontalPager(
        state = state,
        contentPadding = PaddingValues(horizontal = 64.dp),
        modifier = modifier.fillMaxWidth(),
        pageSpacing = 32.dp,
    ) {
        val index = it.mod(count)
        val isCentralPage = index == state.currentPage.mod(count)

        val cardModifier = if (isCentralPage) {
            Modifier
                .aspectRatio(1f)
                .scale(1.1f)
                .clickable {
                    navController.navigate("result")
                }
        } else {
            Modifier.aspectRatio(1f)
        }

        Card(
            modifier = cardModifier,
            colors = CardDefaults.cardColors(
                containerColor = if (isCentralPage) Color.Gray else Color.LightGray
            )
        ) {
            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center,
            ) {
                Text(
                    text = "何が出るかな?",
                    style = MaterialTheme.typography.headlineLarge,
                    color = Color.White,
                )
            }
        }
    }
}

@Composable
fun Result() {
    val scope = rememberCoroutineScope()
    val result = getRandomResult()
    var currentResult by remember { mutableStateOf("スワイプしてください") }
    var isSwiped by remember { mutableStateOf(false) }

    var totalDragDistance = 0f
    val thresholdDistance = 100.dp

    Box(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(isSwiped) {
                if (!isSwiped) {
                    detectHorizontalDragGestures(
                        onHorizontalDrag = { change, dragAmount ->
                            totalDragDistance += dragAmount
                            if (totalDragDistance.absoluteValue > thresholdDistance.toPx()) {
                                scope.launch {
                                    currentResult = result
                                    isSwiped = true
                                }
                            }
                        },
                        onDragEnd = {

                        }
                    )
                }
            },
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = currentResult,
            fontSize = 32.sp,
        )
    }
}

private fun getRandomResult(): String {
    val items = listOf(
        "♢" to 50.0,
        "♢♢" to 20.0,
        "♢♢♢" to 10.0,
        "♢♢♢♢" to 6.664,
        "☆" to 10.288,
        "☆☆" to 2.0,
        "☆☆☆" to 0.888,
        "👑" to 0.160
    )
    val cumulativeProbabilities = items.foldIndexed(listOf<Pair<String, Double>>()) { _, acc, (item, probability) ->
        val cumulativeProbability = if (acc.isEmpty()) probability else acc.last().second + probability
        acc + (item to cumulativeProbability)
    }

    val randomValue = Random.nextDouble(0.0, 100.0)

    return cumulativeProbabilities.first { randomValue <= it.second }.first

}
```

# さいごに
ここまで見ていただきありがとうございました。
これでみなさんも好きなときに何回でも運試しができるようになりました!
今後余力があれば本家のようにカードが円を描くように並べて、もう少しスムーズにカードを選べるようにしたいです。

ポケポケもAndroidアプリ開発も一緒に精進していきましょう!!

明日の [レコチョク Advent Calendar 2024](https://qiita.com/advent-calendar/2024/recochoku) は12日目「【MagicPod】Webとアプリを跨いだ機能の自動UIテスト」です。お楽しみに！ 


---
この記事はレコチョクのエンジニアブログの記事を転載したものとなります。 

https://techblog.recochoku.jp/11351