【SwiftUI/JetpackCompose】ホイールピッカーをつくる　~比較で覚える宣言的モバイルUI~

SwiftUIとJetpackComposeで、同セグメントの機能を両フレームワークでつくろうとした時に
- きれいに対応関係がまとまっている
- コピペで動く
- 最もシンプルな実装

そんな記事があったらいいなと思い、備忘録も兼ねて自分が実装してみた範囲でまとめていきたいと思います。初心者ですので、より良い実装をご存知の方がいらっしゃいましたら、ご教示ください。

今回は**ホイールピッカー編**です。

:::note warn
なお、見た目をSwiftUI寄りにしがちです(そっちしか知らないだけ...)
:::

# 対応関係

【SwiftUI】Pickerに.pickerStyle(WheelPickerStyle())の修飾子をつける
【JetpackCompose】自作ホイールピッカー (NumberPickerという呼称が一般的のよう)

# SwiftUI

## Picker + WheelPickerStyle()
Picker構造体
```swift:https://developer.apple.com
struct Picker<Label, SelectionValue, Content> 
    where Label : View, SelectionValue : Hashable, Content : View
```
PickerStyle (Pickerに修飾子の形でつける)
```swift:https://developer.apple.com
DefaultPickerStyle
InlinePickerStyle
MenuPickerStyle
NavigationLinkPickerStyle
PopUpButtonPickerStyle
RadioGroupPickerStyle
SegmentedPickerStyle
WheelPickerStyle  <- これを使う
```

### 具体例
Pickerに修飾子の形でWheelのPickerStyleを適用します。
<details><summary>コードはこちら</summary>

```swift
struct WheelPickerView: View {
    @State var selection = 0
    
    var body: some View {
        VStack {
            Text("Favorite: \(selection)")
            
            Picker(
                selection: $selection, label: Text("Animal")
            ) {
                Text("Dog 🐶").tag(0)
                Text("Cat 🐱").tag(1)
                Text("Rabbit 🐰").tag(2)
                Text("Turtle 🐢").tag(3)
                Text("Rizard 🦎").tag(4)
                Text("Snake 🐍").tag(5)
            }
            .pickerStyle(WheelPickerStyle())
        }
    }
}
```
</details>

### 実行結果
遠近感と立体感のあるホイールピッカーができました。
![swiftui_picker.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201911/391a8786-f8b9-ea5d-9d12-bd642fe74bb5.png)

# JetpackCompose

## 自作ホイールピッカー
自分の場合は、[こちら](https://github.com/ChargeMap/Compose-NumberPicker)のListItemPickerを参考に、よりフェードが滑らかになるよう一部改造させていただいております。(Android公式のものに近かったのと、型がなんでもOKなものだったため)。これ自分でゼロから作るの想像つきません...。開発者の方に心から敬意と感謝を表します。
他にも、[よりiOSに寄せたもの](https://github.com/commandiron/WheelPickerCompose)や、[スマートな実装のNumberPicker](https://gist.github.com/vganin/a9a84653a9f48a2d669910fbd48e32d5)などのライブラリやスニペットがありました。

<details><summary>コードはこちら</summary>

```kotlin
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.*
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ProvideTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.turtlekazu.qiita.ui.theme.QiitaTheme
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt

@Composable
fun <T> ListItemPicker(
    modifier: Modifier = Modifier,
    label: (T) -> String = { it.toString() },
    value: T,
    onValueChange: (T) -> Unit,
    dividersColor: Color = MaterialTheme.colors.primary,
    list: List<T>,
    textStyle: TextStyle = LocalTextStyle.current,
) {
    val verticalMargin = 8.dp
    val numbersColumnHeight = 80.dp
    val halfNumbersColumnHeight = numbersColumnHeight / 2
    val halfNumbersColumnHeightPx = with(LocalDensity.current) { halfNumbersColumnHeight.toPx() }

    val coroutineScope = rememberCoroutineScope()

    val animatedOffset = remember { Animatable(0f) }
        .apply {
            val index = list.indexOf(value)
            val offsetRange = remember(value, list) {
                -((list.count() - 1) - index) * halfNumbersColumnHeightPx to
                        index * halfNumbersColumnHeightPx
            }
            updateBounds(offsetRange.first, offsetRange.second)
        }

    val coercedAnimatedOffset = animatedOffset.value % halfNumbersColumnHeightPx

    val indexOfElement = getItemIndexForOffset(list, value, animatedOffset.value, halfNumbersColumnHeightPx)

    var dividersWidth by remember { mutableStateOf(0.dp) }

    Layout(
        modifier = modifier
            .draggable(
                orientation = Orientation.Vertical,
                state = rememberDraggableState { deltaY ->
                    coroutineScope.launch {
                        animatedOffset.snapTo(animatedOffset.value + deltaY)
                    }
                },
                onDragStopped = { velocity ->
                    coroutineScope.launch {
                        val endValue = animatedOffset.fling(
                            initialVelocity = velocity,
                            animationSpec = exponentialDecay(frictionMultiplier = 20f),
                            adjustTarget = { target ->
                                val coercedTarget = target % halfNumbersColumnHeightPx
                                val coercedAnchors =
                                    listOf(-halfNumbersColumnHeightPx, 0f, halfNumbersColumnHeightPx)
                                val coercedPoint = coercedAnchors.minByOrNull { abs(it - coercedTarget) }!!
                                val base = halfNumbersColumnHeightPx * (target / halfNumbersColumnHeightPx).toInt()
                                coercedPoint + base
                            }
                        ).endState.value

                        val result = list.elementAt(
                            getItemIndexForOffset(list, value, endValue, halfNumbersColumnHeightPx)
                        )
                        onValueChange(result)
                        animatedOffset.snapTo(0f)
                    }
                }
            )
            .padding(vertical = numbersColumnHeight / 3 + verticalMargin * 2),
        content = {
            Box(modifier.width(dividersWidth).height(2.dp).background(color = dividersColor))

            Box(
                modifier = Modifier
                    .padding(vertical = verticalMargin, horizontal = 20.dp)
                    .offset { IntOffset(x = 0, y = coercedAnimatedOffset.roundToInt()) },
                contentAlignment = Alignment.Center,
            ) {
                ProvideTextStyle(textStyle) {
                    if (indexOfElement > 1)
                        Label(
                            text = label(list.elementAt(indexOfElement - 2)),
                            modifier = modifier
                                .offset(y = -numbersColumnHeight)
                                .alpha(coercedAnimatedOffset / halfNumbersColumnHeightPx)
                        )
                    if (indexOfElement > 0)
                        Label(
                            text = list.elementAt(indexOfElement - 1).toString(),
                            modifier = modifier
                                .offset(y = -halfNumbersColumnHeight)
                                .alpha((coercedAnimatedOffset / halfNumbersColumnHeightPx) * 0.5f + 0.5f)
                        )
                    Label(
                        text = list.elementAt(indexOfElement).toString(),
                        modifier = modifier
                            .alpha(1 - (abs(coercedAnimatedOffset) / halfNumbersColumnHeightPx) * 0.5f)
                    )
                    if (indexOfElement < list.count() - 1)
                        Label(
                            text = list.elementAt(indexOfElement + 1).toString(),
                            modifier = modifier
                                .offset(y = halfNumbersColumnHeight)
                                .alpha(-(coercedAnimatedOffset / halfNumbersColumnHeightPx) * 0.5f + 0.5f)
                        )
                    if (indexOfElement < list.count() - 2)
                        Label(
                            text = list.elementAt(indexOfElement + 2).toString(),
                            modifier = modifier
                                .offset(y = numbersColumnHeight)
                                .alpha(-(coercedAnimatedOffset / halfNumbersColumnHeightPx) * 0.5f)
                        )
                }
            }

            Box(modifier.width(dividersWidth).height(2.dp).background(color = dividersColor))
        }
    ) { measurables, constraints ->
        val placeables = measurables.map { measurable ->
            measurable.measure(constraints)
        }

        dividersWidth = placeables.drop(1).first().width.toDp()

        layout(dividersWidth.toPx().toInt(), placeables
            .sumOf {
                it.height
            }
        ) {
            var yPosition = 0
            placeables.forEach { placeable ->
                placeable.placeRelative(x = 0, y = yPosition)
                yPosition += placeable.height
            }
        }
    }
}

@Composable
private fun Label(text: String, modifier: Modifier) {
    Text(
        modifier = modifier,
        text = text,
        textAlign = TextAlign.Center,
        color = MaterialTheme.colors.onPrimary,
    )
}

private fun <T> getItemIndexForOffset(
    range: List<T>,
    value: T,
    offset: Float,
    halfNumbersColumnHeightPx: Float
): Int {
    val indexOf = range.indexOf(value) - (offset / halfNumbersColumnHeightPx).toInt()
    return maxOf(0, minOf(indexOf, range.count() - 1))
}

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(
            targetValue = adjustedTarget,
            initialVelocity = initialVelocity,
            block = block
        )
    } else {
        animateDecay(
            initialVelocity = initialVelocity,
            animationSpec = animationSpec,
            block = block,
        )
    }
}
```
</details>

### 具体例
```kotlin
@Composable
fun ListItemPickerExample() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center,
    ) {
        var value by remember { mutableStateOf("test1") }
        ListItemPicker( // <- 作成したPicker
            value = value,
            onValueChange = {value = it},
            list = listOf("test1", "test2", "test3", "test4", "test5"),
            dividersColor = Color.Gray
        )
    }
}
```

### 実行結果
いい感じに動作するホイールピッカーができました。
![jetpack_compose_picker.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201911/aedc9b73-2930-bb24-f6c1-4dcad321ad7a.gif)

# まとめ
**ホイールピッカー**
対応関係を再掲します。
【SwiftUI】Pickerに.pickerStyle(WheelPickerStyle())の修飾子をつける
【JetpackCompose】自作ホイールピッカー (NumberPickerという呼称が一般的のよう)

SwiftUIびいきな感じになってしまいましたが、基本Androidは選択肢にドロップダウンリストを使うみたいですので、ホイールピッカーをそもそもあまり重視していないのだと思われます。その辺の違いも興味深いです。次はJetpackComposeの方が楽に実装できるものを取り上げてみたいと思います。