-
-
Save vganin/a9a84653a9f48a2d669910fbd48e32d5 to your computer and use it in GitHub Desktop.
@Composable | |
fun NumberPicker( | |
state: MutableState<Int>, | |
modifier: Modifier = Modifier, | |
range: IntRange? = null, | |
textStyle: TextStyle = LocalTextStyle.current, | |
onStateChanged: (Int) -> Unit = {}, | |
) { | |
val coroutineScope = rememberCoroutineScope() | |
val numbersColumnHeight = 36.dp | |
val halvedNumbersColumnHeight = numbersColumnHeight / 2 | |
val halvedNumbersColumnHeightPx = with(LocalDensity.current) { halvedNumbersColumnHeight.toPx() } | |
fun animatedStateValue(offset: Float): Int = state.value - (offset / halvedNumbersColumnHeightPx).toInt() | |
val animatedOffset = remember { Animatable(0f) }.apply { | |
if (range != null) { | |
val offsetRange = remember(state.value, range) { | |
val value = state.value | |
val first = -(range.last - value) * halvedNumbersColumnHeightPx | |
val last = -(range.first - value) * halvedNumbersColumnHeightPx | |
first..last | |
} | |
updateBounds(offsetRange.start, offsetRange.endInclusive) | |
} | |
} | |
val coercedAnimatedOffset = animatedOffset.value % halvedNumbersColumnHeightPx | |
val animatedStateValue = animatedStateValue(animatedOffset.value) | |
Column( | |
modifier = modifier | |
.wrapContentSize() | |
.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 % halvedNumbersColumnHeightPx | |
val coercedAnchors = listOf(-halvedNumbersColumnHeightPx, 0f, halvedNumbersColumnHeightPx) | |
val coercedPoint = coercedAnchors.minByOrNull { abs(it - coercedTarget) }!! | |
val base = halvedNumbersColumnHeightPx * (target / halvedNumbersColumnHeightPx).toInt() | |
coercedPoint + base | |
} | |
).endState.value | |
state.value = animatedStateValue(endValue) | |
onStateChanged(state.value) | |
animatedOffset.snapTo(0f) | |
} | |
} | |
) | |
) { | |
val spacing = 4.dp | |
val arrowColor = MaterialTheme.colors.onSecondary.copy(alpha = ContentAlpha.disabled) | |
Arrow(direction = ArrowDirection.UP, tint = arrowColor) | |
Spacer(modifier = Modifier.height(spacing)) | |
Box( | |
modifier = Modifier | |
.align(Alignment.CenterHorizontally) | |
.offset { IntOffset(x = 0, y = coercedAnimatedOffset.roundToInt()) } | |
) { | |
val baseLabelModifier = Modifier.align(Alignment.Center) | |
ProvideTextStyle(textStyle) { | |
Label( | |
text = (animatedStateValue - 1).toString(), | |
modifier = baseLabelModifier | |
.offset(y = -halvedNumbersColumnHeight) | |
.alpha(coercedAnimatedOffset / halvedNumbersColumnHeightPx) | |
) | |
Label( | |
text = animatedStateValue.toString(), | |
modifier = baseLabelModifier | |
.alpha(1 - abs(coercedAnimatedOffset) / halvedNumbersColumnHeightPx) | |
) | |
Label( | |
text = (animatedStateValue + 1).toString(), | |
modifier = baseLabelModifier | |
.offset(y = halvedNumbersColumnHeight) | |
.alpha(-coercedAnimatedOffset / halvedNumbersColumnHeightPx) | |
) | |
} | |
} | |
Spacer(modifier = Modifier.height(spacing)) | |
Arrow(direction = ArrowDirection.DOWN, tint = arrowColor) | |
} | |
} | |
@Composable | |
private fun Label(text: String, modifier: Modifier) { | |
Text( | |
text = text, | |
modifier = modifier.pointerInput(Unit) { | |
detectTapGestures(onLongPress = { | |
// FIXME: Empty to disable text selection | |
}) | |
} | |
) | |
} | |
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, | |
) | |
} | |
} |
@Preview | |
@Composable | |
fun PreviewNumberPicker() { | |
Box(modifier = Modifier.fillMaxSize()) { | |
NumberPicker( | |
state = remember { mutableStateOf(9) }, | |
range = 0..10, | |
modifier = Modifier.align(Alignment.Center) | |
) | |
} | |
} |
Hey @vganin. Thanks for the great implementation. I want another implementation like this one but it should be automatically scrolled.
Imagine this: we have two cards with this number picker. one has 60 numbers and another one has 10. the animation speed for both of them should be 3000 ms and they should start simultaneously. Can you help me build this?
Hey @vganin. Thanks for the great implementation. I want another implementation like this one but it should be automatically scrolled. Imagine this: we have two cards with this number picker. one has 60 numbers and another one has 10. the animation speed for both of them should be 3000 ms and they should start simultaneously. Can you help me build this?
Do you mean like when you scroll one picker, then the other scrolls automatically as if you would scroll them simultaneously? Maybe sharing single MutableInteractionSource
using remember
between two pickers is what you want?
Hey @vganin Thanks for this amazing implementation, I want to ask you about the licence, Is it available under Apache 2.0 license?
There is a generic version here with wrapSelectorWheel parameter
The fling should work I think 🤔 Maybe it's not that apparent as you want it to be. Try playing with the parameters inside
fling
extension. Mainly you should always fall inside the first branch withanimateTo
, so the following setup gives much more apparent look to the fling