-
-
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) | |
) | |
} | |
} |
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 isLabel
s where the things likeanimatedStateValue - 1
andanimatedStateValue + 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!
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 isLabel
s where the things likeanimatedStateValue - 1
andanimatedStateValue + 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.
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! 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]
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! 😃
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.
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
)
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
I love this implementation, you gave me way to build custom date picker with compose