From 6c379ec0abba9413003766cba89b4596f26fbfa9 Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 25 Dec 2021 16:57:35 -0500 Subject: [PATCH] Add ListItemPicker and examples --- .../compose/numberpicker/ListItemPicker.kt | 226 ++++++++++++++++++ .../android/sample/MainActivityUI.kt | 41 ++++ 2 files changed, 267 insertions(+) create mode 100644 lib/src/main/kotlin/com/chargemap/compose/numberpicker/ListItemPicker.kt diff --git a/lib/src/main/kotlin/com/chargemap/compose/numberpicker/ListItemPicker.kt b/lib/src/main/kotlin/com/chargemap/compose/numberpicker/ListItemPicker.kt new file mode 100644 index 0000000..3d4c384 --- /dev/null +++ b/lib/src/main/kotlin/com/chargemap/compose/numberpicker/ListItemPicker.kt @@ -0,0 +1,226 @@ +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: List, + value: T, + offset: Float, + halfNumbersColumnHeightPx: Float +): Int { + val indexOf = range.indexOf(value) - (offset / halfNumbersColumnHeightPx).toInt() + return maxOf(0, minOf(indexOf, range.count() - 1)) +} + + +@Composable +fun ListItemPicker( + modifier: Modifier = Modifier, + label: (T) -> String = { it.toString() }, + value: T, + onValueChange: (T) -> Unit, + dividersColor: Color = MaterialTheme.colors.primary, + range: List, + 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 = range.indexOf(value) + val offsetRange = remember(value, range) { + -((range.count() - 1) - index) * halfNumbersColumnHeightPx to + index * halfNumbersColumnHeightPx + } + updateBounds(offsetRange.first, offsetRange.second) + } + + val coercedAnimatedOffset = animatedOffset.value % halfNumbersColumnHeightPx + + val indexOfElement = + getItemIndexForOffset(range, 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 = range.elementAt( + getItemIndexForOffset(range, 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(range.elementAt(indexOfElement - 1)), + modifier = baseLabelModifier + .offset(y = -halfNumbersColumnHeight) + .alpha(maxOf(minimumAlpha, coercedAnimatedOffset / halfNumbersColumnHeightPx)) + ) + Label( + text = label(range.elementAt(indexOfElement)), + modifier = baseLabelModifier + .alpha( + (maxOf( + minimumAlpha, + 1 - abs(coercedAnimatedOffset) / halfNumbersColumnHeightPx + )) + ) + ) + if (indexOfElement < range.count() - 1) + Label( + text = label(range.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, + ) + } +} diff --git a/sample/src/main/kotlin/com/chargemap/android/sample/MainActivityUI.kt b/sample/src/main/kotlin/com/chargemap/android/sample/MainActivityUI.kt index 4994f31..7665b7c 100644 --- a/sample/src/main/kotlin/com/chargemap/android/sample/MainActivityUI.kt +++ b/sample/src/main/kotlin/com/chargemap/android/sample/MainActivityUI.kt @@ -140,8 +140,49 @@ fun MainActivityUI() { ) } ) + DoublesPicker() + FruitPicker() + IntRangePicker() } } } } +} + +@Composable +private fun DoublesPicker() { + val possibleValues = generateSequence(0.5f, {it + 0.25f} ) + .takeWhile { it <= 5f } + .toList() + var value by remember { mutableStateOf(possibleValues[0]) } + ListItemPicker( + label = { it.toString() }, + value = value, + onValueChange = { value = it }, + range = possibleValues + ) +} + +@Composable +private fun FruitPicker() { + val possibleValues = listOf("Apples", "Oranges", "Peaches", "Tomatoes") + var value: String by remember { mutableStateOf(possibleValues[0]) } + ListItemPicker( + label = { it }, + value = value, + onValueChange = { value = it }, + range = possibleValues + ) +} + +@Composable +private fun IntRangePicker() { + val possibleValues = (-5..10).toList() + var value by remember { mutableStateOf(possibleValues[0]) } + ListItemPicker( + label = { it.toString() }, + value = value, + onValueChange = { value = it }, + range = possibleValues + ) } \ No newline at end of file