LoginSignup
0
1

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-09-30

SwiftUIとJetpackComposeで、同セグメントの機能を両フレームワークでつくろうとした時に

  • きれいに対応関係がまとまっている
  • コピペで動く
  • 最もシンプルな実装

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

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

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

対応関係

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

SwiftUI

Picker + WheelPickerStyle()

Picker構造体

https://developer.apple.com
struct Picker<Label, SelectionValue, Content> 
    where Label : View, SelectionValue : Hashable, Content : View

PickerStyle (Pickerに修飾子の形でつける)

https://developer.apple.com
DefaultPickerStyle
InlinePickerStyle
MenuPickerStyle
NavigationLinkPickerStyle
PopUpButtonPickerStyle
RadioGroupPickerStyle
SegmentedPickerStyle
WheelPickerStyle  <- これを使う

具体例

Pickerに修飾子の形でWheelのPickerStyleを適用します。

コードはこちら
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())
        }
    }
}

実行結果

遠近感と立体感のあるホイールピッカーができました。
swiftui_picker.png

JetpackCompose

自作ホイールピッカー

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

コードはこちら
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,
        )
    }
}

具体例

@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

まとめ

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

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

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1