@Composable fun NumberPicker( state: MutableState, 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.fling( initialVelocity: Float, animationSpec: DecayAnimationSpec, adjustTarget: ((Float) -> Float)?, block: (Animatable.() -> Unit)? = null, ): AnimationResult { 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, ) } }