Make the Labeled Range Slider interactive
Tags: android, mobile, kotlin, compose
Published: 2022-08-08 06:31:15

We [created a nice looking UI](https://dev.to/lex_fury/draw-the-labeled-range-slider-1771), 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](https://dev.to/lex_fury/touch-interactions-in-jetpack-compose-5be9) 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.

```Kotlin
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**.

```Kotlin
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.

```Kotlin
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.

```Kotlin
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.

![Interactive Labeled Range slider without snap to step](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/itd2s1ys1oxo01xdb29s.gif)
 
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.

```Kotlin
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
}
```

![Labeled Range slider in motion](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ahvggtkcx5avftiykchh.gif)

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.

```Kotlin
@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.

```Kotlin
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

![Labeled Range Slider with output](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l75nlovp07rbvee0xfiy.gif)
 
We did it :tada:. We created our own Labeled Range Slider from scratch, drawing everything our Composable needs ourselves and making it interactive with the respective **Modifier** :partying_face:.

The entire source code of the Labeled Range Slider can be  found on [GitHub](https://github.com/a-frank/labeled-range-slider).

I hope you enjoyed following along this series and had some helpful inspiration :-).