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) | |
) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.