diff --git a/lib/src/main/kotlin/com/chargemap/compose/numberpicker/InfiniteItemPicker.kt b/lib/src/main/kotlin/com/chargemap/compose/numberpicker/InfiniteItemPicker.kt new file mode 100644 index 0000000..8a0b479 --- /dev/null +++ b/lib/src/main/kotlin/com/chargemap/compose/numberpicker/InfiniteItemPicker.kt @@ -0,0 +1,240 @@ +package com.chargemap.compose.numberpicker + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.* +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ProvideTextStyle +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.roundToInt + +private fun getItemIndexForOffset( + range: InfiniteGetter, + value: T, + offset: Float, + halfNumbersColumnHeightPx: Float +): Int { + val indexOf = range.indexOf(value) - (offset / halfNumbersColumnHeightPx).toInt() + return maxOf(0, indexOf) +} + +data class InfiniteGetter( + private val itemGetter: (index : Int) -> T, + private val inverseGetter : (T) -> Int +) { + fun indexOf(value : T) : Int { + return inverseGetter(value) + } + fun count() : Int = Int.MAX_VALUE + fun elementAt(index: Int) : T { + if (index < 0) { + throw IndexOutOfBoundsException() + } + return itemGetter(index) + } +} + +@Composable +fun InfiniteItemPicker( + modifier: Modifier = Modifier, + label: (T) -> String = { it.toString() }, + value: T, + onValueChange: (T) -> Unit, + dividersColor: Color = MaterialTheme.colors.primary, + infiniteGetter: InfiniteGetter, + textStyle: TextStyle = LocalTextStyle.current, +) { + val minimumAlpha = 0.3f + val verticalMargin = 8.dp + val numbersColumnHeight = 80.dp + val halfNumbersColumnHeight = numbersColumnHeight / 2 + val halfNumbersColumnHeightPx = with(LocalDensity.current) { halfNumbersColumnHeight.toPx() } + + val coroutineScope = rememberCoroutineScope() + + val animatedOffset = remember { Animatable(0f) } + .apply { + val index = infiniteGetter.indexOf(value) + val offsetRange = remember(value, infiniteGetter) { + -((infiniteGetter.count() - 1) - index) * halfNumbersColumnHeightPx to + index * halfNumbersColumnHeightPx + } + updateBounds(offsetRange.first, offsetRange.second) + } + + val coercedAnimatedOffset = animatedOffset.value % halfNumbersColumnHeightPx + + val indexOfElement = getItemIndexForOffset(infiniteGetter, value, animatedOffset.value, halfNumbersColumnHeightPx) + + var dividersWidth by remember { mutableStateOf(0.dp) } + + Layout( + modifier = modifier + .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 % halfNumbersColumnHeightPx + val coercedAnchors = + listOf(-halfNumbersColumnHeightPx, 0f, halfNumbersColumnHeightPx) + val coercedPoint = coercedAnchors.minByOrNull { abs(it - coercedTarget) }!! + val base = halfNumbersColumnHeightPx * (target / halfNumbersColumnHeightPx).toInt() + coercedPoint + base + } + ).endState.value + + val result = infiniteGetter.elementAt( + getItemIndexForOffset(infiniteGetter, value, endValue, halfNumbersColumnHeightPx) + ) + onValueChange(result) + animatedOffset.snapTo(0f) + } + } + ) + .padding(vertical = numbersColumnHeight / 3 + verticalMargin * 2), + content = { + Box( + modifier + .width(dividersWidth) + .height(2.dp) + .background(color = dividersColor) + ) + Box( + modifier = Modifier + .padding(vertical = verticalMargin, horizontal = 20.dp) + .offset { IntOffset(x = 0, y = coercedAnimatedOffset.roundToInt()) } + ) { + val baseLabelModifier = Modifier.align(Alignment.Center) + ProvideTextStyle(textStyle) { + if (indexOfElement > 0) + Label( + text = label(infiniteGetter.elementAt(indexOfElement - 1)), + modifier = baseLabelModifier + .offset(y = -halfNumbersColumnHeight) + .alpha(maxOf(minimumAlpha, coercedAnimatedOffset / halfNumbersColumnHeightPx)) + ) + Label( + text = label(infiniteGetter.elementAt(indexOfElement)), + modifier = baseLabelModifier + .alpha( + (maxOf( + minimumAlpha, + 1 - abs(coercedAnimatedOffset) / halfNumbersColumnHeightPx + )) + ) + ) + if (indexOfElement < infiniteGetter.count() - 1) + Label( + text = label(infiniteGetter.elementAt(indexOfElement + 1)), + modifier = baseLabelModifier + .offset(y = halfNumbersColumnHeight) + .alpha(maxOf(minimumAlpha, -coercedAnimatedOffset / halfNumbersColumnHeightPx)) + ) + } + } + Box( + modifier + .width(dividersWidth) + .height(2.dp) + .background(color = dividersColor) + ) + } + ) { measurables, constraints -> + // Don't constrain child views further, measure them with given constraints + // List of measured children + val placeables = measurables.map { measurable -> + // Measure each children + measurable.measure(constraints) + } + + dividersWidth = placeables + .drop(1) + .first() + .width + .toDp() + + // Set the size of the layout as big as it can + layout(dividersWidth.toPx().toInt(), placeables + .sumOf { + it.height + } + ) { + // Track the y co-ord we have placed children up to + var yPosition = 0 + + // Place children in the parent layout + placeables.forEach { placeable -> + + // Position item on the screen + placeable.placeRelative(x = 0, y = yPosition) + + // Record the y co-ord placed up to + yPosition += placeable.height + } + } + } +} + +@Composable +private fun Label(text: String, modifier: Modifier) { + Text( + modifier = modifier.pointerInput(Unit) { + detectTapGestures(onLongPress = { + // FIXME: Empty to disable text selection + }) + }, + text = text, + textAlign = TextAlign.Center, + ) +} + +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, + ) + } +}