Last active
January 14, 2024 11:53
-
-
Save vganin/a9a84653a9f48a2d669910fbd48e32d5 to your computer and use it in GitHub Desktop.
Jetpack Compose simple number picker
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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, | |
) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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 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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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
usingremember
between two pickers is what you want?