Make the Labeled Range Slider interactive

by:

Softwares

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()

Enter fullscreen mode

Exit fullscreen mode

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)
            
        
    }
Enter fullscreen mode

Exit fullscreen mode

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
    


Enter fullscreen mode

Exit fullscreen mode

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)

Enter fullscreen mode

Exit fullscreen mode

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.

Interactive Labeled Range slider without snap to step

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

Enter fullscreen mode

Exit fullscreen mode

Labeled Range slider in motion

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()
)
Enter fullscreen mode

Exit fullscreen mode

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

Enter fullscreen mode

Exit fullscreen mode

Conclusion

Labeled Range Slider with output

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 :-).

Leave a Reply

Your email address will not be published.