Skip to content

Instantly share code, notes, and snippets.

@vganin
Last active January 14, 2024 11:53
Show Gist options
  • Save vganin/a9a84653a9f48a2d669910fbd48e32d5 to your computer and use it in GitHub Desktop.
Save vganin/a9a84653a9f48a2d669910fbd48e32d5 to your computer and use it in GitHub Desktop.
Jetpack Compose simple number picker
@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)
)
}
}
@nachtien
Copy link

nachtien commented Oct 28, 2022

For anyone who wants to see what it looks like

image

@sweakpl
Copy link

sweakpl commented Nov 2, 2022

Has anyone made the picker fling after fast-dragging the picker? Now the picker is very crude - fast-dragging over the picker does not make the picker spin over next values. The picker scrolls only as long the user is dragging over it.

@vganin
Copy link
Author

vganin commented Nov 2, 2022

Has anyone made the picker fling after fast-dragging the picker? Now the picker is very crude - fast-dragging over the picker does not make the picker spin over next values. The picker scrolls only as long the user is dragging over it.

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 with animateTo, so the following setup gives much more apparent look to the fling

animateTo(
    targetValue = adjustedTarget,
    animationSpec = spring(
        stiffness = Spring.StiffnessVeryLow,
    ),
    initialVelocity = 0f,
    block = block
)

@AlirezaNezami96
Copy link

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?

@vganin
Copy link
Author

vganin commented Nov 18, 2022

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?

@IslamLotfy
Copy link

Hey @vganin Thanks for this amazing implementation, I want to ask you about the licence, Is it available under Apache 2.0 license?

@vganin
Copy link
Author

vganin commented Apr 4, 2023

Hey @vganin Thanks for this amazing implementation, I want to ask you about the licence, Is it available under Apache 2.0 license?

You're very welcome. Yes, it is. This code is actually a part of my open-source app, and the license can be found here.

@inidamleader
Copy link

inidamleader commented Jan 14, 2024

There is a generic version here with wrapSelectorWheel parameter

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment