We created a nice looking UI, yet it is pretty useless at the moment. We still have no way to interact with it. Let's fix that.
Our touch handles should be draggable across our bar, position itself instantly when tapping on it and snap to the nearest value when the interaction is done.
Move it
In part 2 of this series we saw how to use gesture detectors. We could use detectTapGestures and detectDragGestures to achieve this. But since we want to do the more or less same thing when tapping or dragging, position the handle to the touch point, we can use the briefly mentioned awaitPointerEventScope to implement a more flexible and better fitting touch handler.
We can define the touch states we are interested in as a sealed class.
sealed class TouchInteraction {
object NoInteraction : TouchInteraction()
object Up : TouchInteraction()
data class Move(val position: Offset) : TouchInteraction()
}
It is enough for us to know if there is currently no interaction, the handle should be moved to a position and that the user lifted their finger.
Our touch handler is then implemented using the pointerInput Modifier.
fun Modifier.touchInteraction(key: Any, block: (TouchInteraction) -> Unit): Modifier =
pointerInput(key) {
forEachGesture {
awaitPointerEventScope {
do {
val event: PointerEvent = awaitPointerEvent()
event.changes
.forEach { pointerInputChange: PointerInputChange ->
if (pointerInputChange.positionChange() != Offset.Zero) pointerInputChange.consume()
}
block(TouchInteraction.Move(event.changes.first().position))
} while (event.changes.any { it.pressed })
block(TouchInteraction.Up)
}
}
}
We await a touch input from the user with awaitPointerEventScope, when we get one we know the user is now interacting with our Labeled Range Slider. We iterate over the events, as long as the users finger stays on our Composable, we get the absolute position of the event and we pass it on as a TouchInteraction.Move event ourselves. As soon as the user lifts their finger, we respond with TouchInteraction.Up giving our UI the chance to react by snapping the handle to the nearest step.
In our Composable we add the Modifier to the canvas, add three state variables to keep track of the current interaction state and add logic to update the position of our handles.
var touchInteractionState by remember { mutableStateOf<TouchInteraction>(TouchInteraction.NoInteraction) }
var moveLeft by remember { mutableStateOf(false) }
var moveRight by remember { mutableStateOf(false) }
...
Canvas(
modifier = modifier
.touchInteraction(remember { MutableInteractionSource() }) {
touchInteractionState = it
}
) {
...
}
when (val touchInteraction = touchInteractionState) {
is TouchInteraction.Move -> {
val touchPositionX = touchInteraction.position.x
if (abs(touchPositionX - leftCirclePosition.x) < abs(touchPositionX - rightCirclePosition.x)) {
leftCirclePosition = calculateNewLeftCirclePosition(touchPositionX, leftCirclePosition, rightCirclePosition, stepSpacing, stepXCoordinates.first())
moveLeft = true
} else {
rightCirclePosition = calculateNewRightCirclePosition(touchPositionX, leftCirclePosition, rightCirclePosition, stepSpacing, stepXCoordinates.last())
moveRight = true
}
}
is TouchInteraction.Up -> {
moveLeft = false
moveRight = false
touchInteractionState = TouchInteraction.NoInteraction
}
else -> {
// nothing to do
}
}
We need to know which handle to move. For that we look at the x position of the touch interaction, calculate the distance between the left and the right handle and move the handle the interaction was closest to. When calculating the new position of the handle we need to take into account, that the handle should not leave the bar and that the two handles should not overlap while moving. To make it clearer let's have a quick look at the calculation of the updated position of the left handle.
private fun calculateNewLeftCirclePosition(
touchPositionX: Float,
leftCirclePosition: Offset,
rightCirclePosition: Offset,
stepSpacing: Float,
firstStepXPosition: Float
): Offset = when {
touchPositionX < firstStepXPosition -> leftCirclePosition.copy(x = firstStepXPosition)
touchPositionX > (rightCirclePosition.x - stepSpacing) -> leftCirclePosition
else -> leftCirclePosition.copy(x = touchPositionX)
}
As we can see depending on the touch position, the position of the other handle and the spacing of the steps and in this case the position of the first step, we calculate the new position the left handle is allowed to have.
The handles move while touching the slider, we can tap on a position the handle instantly jumps to it and we even can move between the two handles without lifting the finger.
Make it snappy
The handles don't behave how we want it yet. They should snap to the nearest step after the user lifts their finger, as well as when the controlled handle is changed. To make that happen we update our touch interaction logic, finding the nearest step and its x-coordinate and updating the handle position accordingly.
is TouchInteraction.Move -> {
val touchPositionX = touchInteraction.position.x
if (abs(touchPositionX - leftCirclePosition.x) < abs(touchPositionX - rightCirclePosition.x)) {
leftCirclePosition = calculateNewLeftCirclePosition(touchPositionX, leftCirclePosition, rightCirclePosition, stepSpacing, stepXCoordinates.first())
moveLeft = true
if (moveRight) {
val (closestRightValue, _) = stepXCoordinates.getClosestNumber(rightCirclePosition.x)
rightCirclePosition = rightCirclePosition.copy(x = closestRightValue)
moveRight = false
}
} else {
rightCirclePosition = calculateNewRightCirclePosition(touchPositionX, leftCirclePosition, rightCirclePosition, stepSpacing, stepXCoordinates.last())
moveRight = true
if (moveLeft) {
val (closestRightValue, _) = stepXCoordinates.getClosestNumber(leftCirclePosition.x)
leftCirclePosition = leftCirclePosition.copy(x = closestRightValue)
moveLeft = false
}
}
}
is TouchInteraction.Up -> {
val (closestLeftValue, closestLeftIndex) = stepXCoordinates.getClosestNumber(leftCirclePosition.x)
val (closestRightValue, closestRightIndex) = stepXCoordinates.getClosestNumber(rightCirclePosition.x)
if (moveLeft) {
leftCirclePosition = leftCirclePosition.copy(x = closestLeftValue)
moveLeft = false
} else if (moveRight) {
rightCirclePosition = rightCirclePosition.copy(x = closestRightValue)
moveRight = false
}
touchInteractionState = TouchInteraction.NoInteraction
}
Now that already looks like the final result we want to achieve :-). But there is one minor detail missing: We still need to communicate the updated range back to our caller, so they can react on it ;-).
This final step is now pretty easy. We add a callback onRangeChanged as parameter to our Composable.
@Composable
fun <T : Number> LabeledRangeSlider(
selectedLowerBound: T,
selectedUpperBound: T,
steps: List<T>,
onRangeChanged: (lower: T, upper: T) -> Unit,
modifier: Modifier = Modifier,
sliderConfig: SliderConfig = SliderConfig()
)
And simply call it every time the user lifts their finger with the value of the selected steps.
is TouchInteraction.Up -> {
val (closestLeftValue, closestLeftIndex) = stepXCoordinates.getClosestNumber(leftCirclePosition.x)
val (closestRightValue, closestRightIndex) = stepXCoordinates.getClosestNumber(rightCirclePosition.x)
if (moveLeft) {
leftCirclePosition = leftCirclePosition.copy(x = closestLeftValue)
onRangeChanged(steps[closestLeftIndex], steps[closestRightIndex])
moveLeft = false
} else if (moveRight) {
rightCirclePosition = rightCirclePosition.copy(x = closestRightValue)
onRangeChanged(steps[closestLeftIndex], steps[closestRightIndex])
moveRight = false
}
touchInteractionState = TouchInteraction.NoInteraction
}
Conclusion
We did it 🎉. We created our own Labeled Range Slider from scratch, drawing everything our Composable needs ourselves and making it interactive with the respective Modifier 🥳.
The entire source code of the Labeled Range Slider can be found on GitHub.
I hope you enjoyed following along this series and had some helpful inspiration :-).
Top comments (0)