Coverage Summary for Class: NumberPickerKt (com.vsevolodganin.clicktrack.ui.piece)
Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
NumberPickerKt |
0%
(0/9)
|
0%
(0/34)
|
0%
(0/94)
|
0%
(0/1141)
|
NumberPickerKt$Label$1$1 |
0%
(0/2)
|
|
0%
(0/2)
|
0%
(0/15)
|
NumberPickerKt$NumberPicker$3$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/20)
|
NumberPickerKt$NumberPicker$4$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/22)
|
NumberPickerKt$NumberPicker$4$1$1 |
0%
(0/2)
|
|
0%
(0/11)
|
0%
(0/113)
|
Total |
0%
(0/15)
|
0%
(0/34)
|
0%
(0/109)
|
0%
(0/1311)
|
package com.vsevolodganin.clicktrack.ui.piece
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationResult
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.calculateTargetValue
import androidx.compose.animation.core.exponentialDecay
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material.ContentAlpha
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ProvideTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.vsevolodganin.clicktrack.utils.compose.Preview
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
@Composable
fun NumberPicker(
state: MutableState<Int>,
modifier: Modifier = Modifier,
range: IntRange? = null,
textStyle: TextStyle = LocalTextStyle.current,
) {
NumberPicker(
value = state.value,
onValueChange = { state.value = it },
modifier = modifier,
range = range,
textStyle = textStyle,
)
}
@Composable
fun NumberPicker(
value: Int,
onValueChange: (Int) -> Unit,
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 = value - (offset / halvedNumbersColumnHeightPx).toInt()
val animatedOffset = remember { Animatable(0f) }
.apply {
if (range != null) {
val first = -(range.last - value) * halvedNumbersColumnHeightPx
val last = -(range.first - value) * halvedNumbersColumnHeightPx
val offsetRange = 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
onValueChange(animatedStateValue(endValue))
animatedOffset.snapTo(0f)
}
},
),
) {
val spacing = 4.dp
val arrowColor = MaterialTheme.colors.onSurface.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
private fun Preview() {
Box(modifier = Modifier.fillMaxSize()) {
NumberPicker(
state = remember { mutableStateOf(9) },
range = 0..10,
modifier = Modifier.align(Alignment.Center),
)
}
}