アプリ開発備忘録

PlayStationMobile、Android、UWPの開発備忘録

【Compose】Pickerを作成する(ほぼFlingBehaviorのカスタマイズ)

こんな感じのNumber Pickerを作りました。

State

まずは無難にStateの定義。
State経由でPositionの変化を受け取ります。

@Composable
public fun rememberPickerState(): PickerState {
    return remember {
        PickerState()
    }
}

public class PickerState {
    public var currentPosition: Int by mutableStateOf(0)
        internal set
}

本体

DividerはColumnのweightで3等分しました。
LazyColumnの要素には3等分した高さを設定し、先頭と末尾に空の要素を1つ設定しています。
問題は後述のflingBehaviorです。

@OptIn(ExperimentalFoundationApi::class)
@Composable
public fun <T> Picker(
    modifier: Modifier,
    state: PickerState = rememberPickerState(),
    items: ImmutableList<T>,
    content: @Composable (item: T) -> Unit,
) {
    BoxWithConstraints(
        modifier = modifier,
    ) {
        val itemHeight = remember(maxHeight) {
            maxHeight / 3
        }
        Column(Modifier.fillMaxSize()) {
            val spacerModifier = Modifier
                .height(1.dp)
                .fillMaxWidth()
                .background(Color.Gray)
            Spacer(modifier = Modifier.weight(1f))
            Spacer(modifier = spacerModifier)
            Spacer(modifier = Modifier.weight(1f))
            Spacer(modifier = spacerModifier)
            Spacer(modifier = Modifier.weight(1f))
        }

        val lazyListState = rememberLazyListState()
        val itemInfo by remember { derivedStateOf { lazyListState.layoutInfo.visibleItemsInfo } }
        LaunchedEffect(itemInfo) {
            val item = itemInfo.getOrNull(0)
            state.currentPosition = item?.index ?: 0
        }

        LazyColumn(
            modifier = Modifier.fillMaxHeight(),
            state = lazyListState,
            flingBehavior = rememberPickerSnapFlingBehavior(
                lazyListState = lazyListState
            ),
        ) {
            item { Spacer(modifier = Modifier.height(itemHeight)) }
            items(items) { item ->
                Box(modifier = Modifier.height(itemHeight)) {
                    content(item)
                }
            }
            item { Spacer(modifier = Modifier.height(itemHeight)) }
        }
    }
}

FlingBehavior

普通のLazyListと違うのは以下のことです。これを FlingBehavior で実現します。

  • スクロールが遅い事
  • 中途半端な部分に止まった時にSnapして止まること

SnapFlingBehavior というものがあるのですが、これはスクロールがすぐに止まってしまうので使えなさそうでした。うまく設定すればいけるのかな?

@ExperimentalFoundationApi
@Composable
private fun rememberPickerSnapFlingBehavior(
    lazyListState: LazyListState
): FlingBehavior {
    val snappingLayout = remember(lazyListState) { SnapLayoutInfoProvider(lazyListState) }
    val flingSpec = remember {
        exponentialDecay<Float>(frictionMultiplier = 2.0f) // default=1.0f. ここの値でスクロールが遅くなる。
    }
    val snapFlingBehavior = rememberSnapFlingBehavior(snappingLayout) // 元からある
    return remember(flingSpec, snappingLayout) {
        SnapFlingBehavior( // 今回定義したもの
            decayAnimationSpec = flingSpec,
            snapFlingBehavior = snapFlingBehavior,
        )
    }
}

ScrollableDefaults.flingBehavior() から取得できる DefaultFlingBehavior のコードを一部コピーしてきます。変更点は以下の点です。

  • 戻り値は0にするのでその部分のコードを削除
  • スクロールが止まり気味になったら、 SnapFlingBehavior に処理を移動する

foundationのDefaultFlingBehavior

DefaultFlingBehavior.kt

private class DefaultFlingBehavior(
    private val flingDecay: DecayAnimationSpec<Float>
) : FlingBehavior {
    override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
        // come up with the better threshold, but we need it since spline curve gives us NaNs
        return if (abs(initialVelocity) > 1f) {
            var velocityLeft = initialVelocity
            var lastValue = 0f
            AnimationState(
                initialValue = 0f,
                initialVelocity = initialVelocity,
            ).animateDecay(flingDecay) {
                val delta = value - lastValue
                val consumed = scrollBy(delta)
                lastValue = value
                velocityLeft = this.velocity
                // avoid rounding errors and stop if anything is unconsumed
                if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
            }
            velocityLeft
        } else {
            initialVelocity
        }
    }
}

SnapFlingBehavior

こちらがカスタマイズしたものです。

@OptIn(ExperimentalFoundationApi::class)
private class SnapFlingBehavior(
    private val decayAnimationSpec: DecayAnimationSpec<Float>,
    private val snapFlingBehavior: SnapFlingBehavior,
) : FlingBehavior {
    override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
        return if (abs(initialVelocity) > 1f) {
            var lastValue = 0f
            var lastVelocity = 0f // 止まりそうな最後のvelocityを保持して、snapFlingBehaviorに渡す。
            AnimationState(
                initialValue = 0f,
                initialVelocity = initialVelocity,
            ).animateDecay(decayAnimationSpec) {
                val delta = value - lastValue
                val consumed = scrollBy(delta)
                lastValue = value
                lastVelocity = velocity
                if (abs(velocity) < 5) { // ここで止まり気味を判別してcancelする
                    this.cancelAnimation()
                }

                if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
            }
            with(snapFlingBehavior) { // snapFlingBehaviorでSnapさせる。
                performFling(lastVelocity)
            }
            0f
        } else {
            with(snapFlingBehavior) {
                performFling(initialVelocity)
            }
            0f
        }
    }
}

Previewコード

Previewコード

@Preview(showBackground = true)
@Composable
private fun Preview() {
    val items = remember {
        (0..10).toList().toImmutableList()
    }

    Column(
        Modifier
            .height(200.dp)
            .width(200.dp)
            .padding(horizontal = 12.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        val state = rememberPickerState()
        Picker(
            modifier = Modifier
                .weight(1f)
                .fillMaxWidth(),
            items = items,
            state = state,
        ) {
            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center,
            ) {
                Text(
                    text = it.toString(),
                )
            }
        }
        Spacer(
            modifier = Modifier
                .height(1.dp)
                .fillMaxWidth()
                .background(Color.Cyan)
        )
        Text(
            modifier = Modifier.padding(8.dp),
            text = "selected = ${state.currentPosition}",
        )
    }
}