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)
)
}
}
@rnett
Copy link

rnett commented Apr 10, 2021

Arrow is unresolved, at least on Compose for Desktop. Is there a replacement?

@vganin
Copy link
Author

vganin commented Apr 10, 2021

Arrow is unresolved, at least on Compose for Desktop. Is there a replacement?

Hey, yeah, Arrow is just my composable which is basically Icon with my custom vector resource for arrow. You can just replace it with any other icon. Try using some of builtin material icons from Icons.Default https://fonts.google.com/icons?selected=Material+Icons&icon.query=arrow.

@Gepro83
Copy link

Gepro83 commented May 8, 2021

Hi, nice widget!
I am new to compose and want to add a callback when a number was picked (after drag & drop).
I tried to add a lambda at line 53 that calls the viewmodel with the new value (which then causes another textfield to change). This works, however, the state of the number picker gehts lost somehow, i.e. immediately jumps back to the value before the drag & drop.
Maybe you can help me :)

@vganin
Copy link
Author

vganin commented May 9, 2021

Hi, nice widget!
I am new to compose and want to add a callback when a number was picked (after drag & drop).
I tried to add a lambda at line 53 that calls the viewmodel with the new value (which then causes another textfield to change). This works, however, the state of the number picker gehts lost somehow, i.e. immediately jumps back to the value before the drag & drop.
Maybe you can help me :)

Hi! You're doing it all right! I updated the gist with what worked for me, check it out. If that didn't work for you, I think you have a problem with state hoisting. Maybe you are not remember'ing state in call site? Probably every time you update viewmodel, a new MutableState with old value gets propagated to the widget. Try debugging it with breakpoints or some logging to find what is the source of old value.

Another way of doing it (to make it idiomatic Compose and ViewModel friendly) is to get rid of state: MutableState<Int> altogether and replace it with pair:

value: Int,
onValueChange: (Int) -> Unit,

Then you will automatically get a callback about a value being changed but you will need to set it manually by calling composable with new value. That's how stock composables work for most part. For this approach take a look at https://developer.android.com/jetpack/compose/state#stateless-composables.

@mattinger
Copy link

mattinger commented Jun 30, 2021

This is nice. Is there a version that mimics the visuals of the built in NumberPicker view?

@vganin
Copy link
Author

vganin commented Jul 2, 2021

Is there a version that mimics the visuals of the built in NumberPicker view?

Not that I know of, but you could build that upon this solution.

@prayansh
Copy link

I love this implementation 😍 . I am trying to build on top of this to support overflowing, so like if u scroll past the last number, u end up reaching the first one and vice-versa. Any ideas on how I could achieve that?

@vganin
Copy link
Author

vganin commented Aug 24, 2021

I love this implementation 😍 . I am trying to build on top of this to support overflowing, so like if u scroll past the last number, u end up reaching the first one and vice-versa. Any ideas on how I could achieve that?

That should be easy. Take a look at internal function animatedStateValue(offset: Float). You can put any logic that converts offset to any new number there. The second thing is Labels where the things like animatedStateValue - 1 and animatedStateValue + 1 are. You should update presentation logic there accordingly.

@masokaya
Copy link

masokaya commented Nov 2, 2021

I love this implementation, you gave me way to build custom date picker with compose

@sweakpl
Copy link

sweakpl commented Mar 20, 2022

I love this implementation 😍 . I am trying to build on top of this to support overflowing, so like if u scroll past the last number, u end up reaching the first one and vice-versa. Any ideas on how I could achieve that?

That should be easy. Take a look at internal function animatedStateValue(offset: Float). You can put any logic that converts offset to any new number there. The second thing is Labels where the things like animatedStateValue - 1 and animatedStateValue + 1 are. You should update presentation logic there accordingly.

Has anyone actually implemented the overflow behavior? I've tried and it doesn't seem like just changing the code in the places mentioned by @vganin is enough. From what I understood so far is that the fling method or the parameters passed to it are also supposed to be changed, but I don't know how yet. If anyone has successfully done that, then please, reach out here!

@MiriHa
Copy link

MiriHa commented May 18, 2022

I love this implementation 😍 . I am trying to build on top of this to support overflowing, so like if u scroll past the last number, u end up reaching the first one and vice-versa. Any ideas on how I could achieve that?

That should be easy. Take a look at internal function animatedStateValue(offset: Float). You can put any logic that converts offset to any new number there. The second thing is Labels where the things like animatedStateValue - 1 and animatedStateValue + 1 are. You should update presentation logic there accordingly.

Has anyone actually implemented the overflow behavior? I've tried and it doesn't seem like just changing the code in the places mentioned by @vganin is enough. From what I understood so far is that the fling method or the parameters passed to it are also supposed to be changed, but I don't know how yet. If anyone has successfully done that, then please, reach out here!

@sweakpl You would need to additionally change the bounds in animatedOffset as this restrains the fling from my understanding. For example changing updateBounds(offsetRange.start, offsetRange.endInclusive) to updateBounds(Float.NEGATIVE_INFINITY , Float.POSITIVE_INFINITY) as well as changing up the animatedStateValue and the displayed text for the Label s worked for me.

@gaborrosta
Copy link

Hi!
I would like to use this picker in my project but I want to change the alpha of the labels. I mean I want that the alpha of the layers do not go to 0 just 0.5F. So the alpha is minimum 0.5F. I tried to change coercedAnimatedOffset, but no luck so far. Thank you in advance for any help!

@vganin
Copy link
Author

vganin commented Sep 20, 2022

Hi! I would like to use this picker in my project but I want to change the alpha of the labels. I mean I want that the alpha of the layers do not go to 0 just 0.5F. So the alpha is minimum 0.5F. I tried to change coercedAnimatedOffset, but no luck so far. Thank you in advance for any help!

Hi! Try applying scaling function to every alpha. If you have normal alpha in range [0, 1], then scaling it with function fun Float.scale() = this / 2f + 0.5f will make it in range [0.5, 1]

@gaborrosta
Copy link

Hi! I would like to use this picker in my project but I want to change the alpha of the labels. I mean I want that the alpha of the layers do not go to 0 just 0.5F. So the alpha is minimum 0.5F. I tried to change coercedAnimatedOffset, but no luck so far. Thank you in advance for any help!

Hi! Try applying scaling function to every alpha. If you have normal alpha in range [0, 1], then scaling it with function fun Float.scale() = this / 2f + 0.5f will make it in range [0.5, 1]

Thank you! It worked! 😃

@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