Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Jetpack Compose implementation of simple integer picker. Supports fling gestures
@Composable
fun NumberPicker(
state: MutableState<Int>,
modifier: Modifier = Modifier,
range: IntRange? = null,
textStyle: TextStyle = LocalTextStyle.current,
) {
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)
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 = mutableStateOf(9),
range = 0..10,
modifier = Modifier.align(Alignment.Center)
)
}
}
@rnett

This comment has been minimized.

Copy link

@rnett rnett commented Apr 10, 2021

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

@vganin

This comment has been minimized.

Copy link
Owner Author

@vganin 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment