Jetpack ComposeでiOSっぽいWheelPickerを実装する

# はじめに
今回はJetpackComopseをつかってIOSっぽいWheelPickerを実装していきます
### コード
```kotlin
@Composable
fun <T> ListItemPicker(
    modifier: Modifier = Modifier,
    label: (T) -> String = { it.toString() },
    value: T,
    onValueChange: (T) -> Unit,
    list: List<T>,
    textStyle: TextStyle = LocalTextStyle.current,
) {
    ListItemPicker(
        modifier = modifier,
        itemContent = { DefaultItem(label(it)) },
        value = value,
        onValueChange = onValueChange,
        list = list,
        textStyle = textStyle,
    )
}

@Composable
fun DefaultItem(text: String) {
    Text(
        modifier = Modifier.pointerInput(Unit) {
            detectTapGestures(onLongPress = {
                // Empty to disable text selection
            })
        },
        text = text,
        textAlign = TextAlign.Center,
    )
}

// 現在のドラッグオフセットから選択インデックスを算出するユーティリティ
private fun <T> getItemIndexForOffset(
    range: List<T>,
    value: T,
    offset: Float,
    itemHeightPx: Float,
): Int {
    val offsetIndex = (offset / itemHeightPx).toInt()
    val newIndex = range.indexOf(value) - offsetIndex
    return newIndex.coerceIn(0, range.lastIndex)
}

// アニメーションの目標位置を、項目単位でスナップさせる補正関数
private fun getSnappedOffset(target: Float, itemHeightPx: Float): Float {
    val base = itemHeightPx * (target / itemHeightPx).toInt()
    val offsetWithinItem = target % itemHeightPx
    val anchors = listOf(-itemHeightPx, 0f, itemHeightPx)
    val closestAnchor = anchors.minByOrNull { abs(it - offsetWithinItem) }!!
    return base + closestAnchor
}

// fling アニメーションで最終位置をスナップしつつオフセットを更新する拡張関数
private suspend fun Animatable<Float, AnimationVector1D>.flingWithSnapping(
    velocity: Float,
    itemHeightPx: Float
): Float {
    val decay = exponentialDecay<Float>(frictionMultiplier = 20f)
    val result = fling(
        initialVelocity = velocity,
        animationSpec = decay,
        adjustTarget = { getSnappedOffset(it, itemHeightPx) }
    )
    return result.endState.value
}

// 指定のインデックスにアニメーションでスクロールし、選択項目を更新する
private suspend fun <T> animateToIndex(
    animatedOffset: Animatable<Float, AnimationVector1D>,
    fromIndex: Int,
    toIndex: Int,
    itemHeightPx: Float,
    onValueChange: (T) -> Unit,
    list: List<T>
) {
    if (fromIndex == toIndex) return
    val offsetDelta = (fromIndex - toIndex) * itemHeightPx

    // 現在のオフセットを一度リセットしてからアニメーション開始
    animatedOffset.snapTo(0f)
    animatedOffset.animateTo(offsetDelta, animationSpec = tween(300))

    // アニメーション完了後に項目を変更
    onValueChange(list[toIndex])

    // オフセットを再度リセットして中央にスナップ
    animatedOffset.snapTo(0f)
}

/**
 * fling（慣性スクロール）アニメーションを実行し、必要に応じて終了位置を補正（スナップ）する。
 *
 * @param initialVelocity 開始時の速度（ユーザーのスクロール速度）
 * @param animationSpec 減速アニメーションの仕様（例：exponentialDecay）
 * @param adjustTarget アニメーション終了値を補正する関数（例：スナップ処理）
 * @param block アニメーション中に実行したい任意の処理（オプション）
 *
 * @return アニメーション終了時の状態情報（AnimationResult）
 */
private suspend fun Animatable<Float, AnimationVector1D>.fling(
    initialVelocity: Float,
    animationSpec: DecayAnimationSpec<Float>,
    adjustTarget: ((Float) -> Float)?,
    block: (Animatable<Float, AnimationVector1D>.() -> Unit)? = null,
): AnimationResult<Float, AnimationVector1D> {
    // 初期速度から自然減速で計算される最終到達点を算出
    val targetValue = animationSpec.calculateTargetValue(value, initialVelocity)

    // 必要に応じて到達点を補正（例：項目の中央にスナップさせるなど）
    val adjustedTarget = adjustTarget?.invoke(targetValue)

    return if (adjustedTarget != null) {
        // 補正された終了点がある場合：その位置まで animateTo でアニメーション
        animateTo(
            targetValue = adjustedTarget,
            initialVelocity = initialVelocity,
            block = block,
        )
    } else {
        // 補正なしの場合：純粋な減速アニメーションで終了点まで遷移
        animateDecay(
            initialVelocity = initialVelocity,
            animationSpec = animationSpec,
            block = block,
        )
    }
}


@Composable
fun <T> ListItemPicker(
    modifier: Modifier = Modifier,
    itemContent: @Composable BoxScope.(T) -> Unit,
    value: T,
    onValueChange: (T) -> Unit,
    list: List<T>,
    textStyle: TextStyle = LocalTextStyle.current,
) {
    // 視覚的に最小限表示されるアルファ値（薄くなる制限）
    val minimumAlpha = 0.3f

    // ビジュアル調整用の縦マージン
    val verticalMargin = 8.dp

    // 項目高さとその半分（中央表示・スナップ用）
    val pickerHeight = 80.dp
    val halfItemHeight = pickerHeight / 2
    val itemHeightPx = with(LocalDensity.current) { halfItemHeight.toPx() }

    // アニメーションやスクロール処理用の CoroutineScope
    val coroutineScope = rememberCoroutineScope()

    // スクロールオフセットを表すアニメーション可能な状態（中央が0f）
    val animatedOffset = remember { Animatable(0f) }.apply {
        // 選択中項目のインデックスに応じてオフセットの範囲を設定
        val index = list.indexOf(value)
        val range = remember(value, list) {
            -((list.lastIndex - index) * itemHeightPx) to (index * itemHeightPx)
        }
        updateBounds(range.first, range.second)
    }

    // 現在のオフセットを1項目分の高さで正規化（-itemHeightPx..itemHeightPx）
    val coercedOffset = animatedOffset.value % itemHeightPx

    // 実際に表示対象となる要素インデックス（中央表示）
    val currentIndex = getItemIndexForOffset(list, value, animatedOffset.value, itemHeightPx)

    Layout(
        modifier = modifier
            .draggable(
                orientation = Orientation.Vertical,
                state = rememberDraggableState { deltaY ->
                    coroutineScope.launch {
                        // ドラッグ中の移動に合わせてオフセットを即時更新
                        animatedOffset.snapTo(animatedOffset.value + deltaY)
                    }
                },
                onDragStopped = { velocity ->
                    coroutineScope.launch {
                        // ドラッグ終了後に慣性アニメーションしてスナップ
                        val finalOffset = animatedOffset.flingWithSnapping(velocity, itemHeightPx)
                        val targetIndex =
                            getItemIndexForOffset(list, value, finalOffset, itemHeightPx)
                        onValueChange(list[targetIndex])
                        animatedOffset.snapTo(0f)
                    }
                },
            )
            .padding(vertical = pickerHeight / 3 + verticalMargin * 2),
        content = {
            Box(
                modifier = Modifier
                    .padding(vertical = verticalMargin, horizontal = 20.dp)
                    .offset { IntOffset(x = 0, y = coercedOffset.roundToInt()) },
            ) {
                val baseModifier = Modifier.align(Alignment.Center)
                ProvideTextStyle(textStyle) {

                    // 上の要素（1つ前）を描画
                    if (currentIndex > 0) {
                        Box(
                            modifier = baseModifier
                                .offset(y = -halfItemHeight)
                                .alpha(maxOf(minimumAlpha, coercedOffset / itemHeightPx))
                                .height(halfItemHeight)
                                .fillMaxWidth()
                                .clickable {
                                    coroutineScope.launch {
                                        animateToIndex(
                                            animatedOffset, currentIndex, currentIndex - 1,
                                            itemHeightPx, onValueChange, list
                                        )
                                    }
                                },
                            contentAlignment = Alignment.Center
                        ) {
                            itemContent(list[currentIndex - 1])
                        }
                    }

                    // 現在選択中の要素（中央）
                    Box(
                        modifier = baseModifier
                            .fillMaxWidth()
                            .padding(horizontal = 32.dp)
                            .alpha(maxOf(minimumAlpha, 1 - abs(coercedOffset) / itemHeightPx))
                            .background(color = PickerCover, shape = RoundedCornerShape(12.dp))
                            .padding(vertical = 4.dp),
                        contentAlignment = Alignment.Center
                    ) {
                        itemContent(list[currentIndex])
                    }

                    // 下の要素（1つ後）を描画
                    if (currentIndex < list.lastIndex) {
                        Box(
                            modifier = baseModifier
                                .offset(y = halfItemHeight)
                                .alpha(maxOf(minimumAlpha, -coercedOffset / itemHeightPx))
                                .height(halfItemHeight)
                                .fillMaxWidth()
                                .clickable {
                                    coroutineScope.launch {
                                        animateToIndex(
                                            animatedOffset, currentIndex, currentIndex + 1,
                                            itemHeightPx, onValueChange, list
                                        )
                                    }
                                },
                            contentAlignment = Alignment.Center
                        ) {
                            itemContent(list[currentIndex + 1])
                        }
                    }
                }
            }
        }
    ) { measurables, constraints ->
        // 子要素を全て計測
        val placeables = measurables.map { it.measure(constraints) }

        // 高さ合計で親レイアウトを構成
        layout(constraints.maxWidth, placeables.sumOf { it.height }) {
            var yPosition = 0
            placeables.forEach {
                it.placeRelative(0, yPosition)
                yPosition += it.height
            }
        }
    }
}
```
### 最後に
Material3とかから出てくれてもいいのになーとか思いながら実装してました
最近はお互い寄せてきてるので、もしかしたらいずれ不要になってくれるかもですね