Draw the Labeled Range Slider
Tags: android, mobile, kotlin, compose
Published: 2022-08-08 06:30:00

In part 4 of this series let's start putting everything we learned so far, and a little more, together and create our Labeled Range Slider. 

![Labeled Range Slider in action](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/oi0wgya1lh9k0nkvnbp1.gif) 

## Draw the UI
To get started we first need to break down the different elements involved in our Composable and how to draw them.

As we can see on the following image we can break down the Labeled Range Slider into 5 elements.

![Labeled Range Slider components](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lypxzd4cjbwd90d00uba.png)

We have
- Labels above the slider bar, indicating the values available and selected. The color and font style should reflect our selected range (_Red_)
- A rounded bar in the background, guiding our sliders (_Purple_)
- Step markers, indicating all available values on our bar (_Green_)
- An indication of our selected range on our bar itself (_Blue_)
- And finally our slider handles which we want to drag across the bar to select our range (_Orange_)

### Rounded background bar (_Purple_)
The simplest element to start with and to setup our Composable is the gray bar in the background guiding our sliders. Before we get to drawing the bar, we first need to do some preparation like calculating the width and height.

For the width we want our bar to fill the entire width of the available space, with some padding for the sliders, but we will come to that later. 
To get the size of our Composable we can use **Modifier.onSizeChanged** and store that value in a state.
Depending on that value we can determine the with of the rect. For the height, we keep it simple and let the caller configure it, but provide a reasonable default of 12 Dp. 
Also we add a parameter for the bar color and the size of the rounded corners.

```Kotlin
@Composable
fun LabeledRangeSlider(
	modifier: Modifier = Modifier,
	barHeight: Dp = 12.dp,
	barColor: Color = Color.LightGray,
	barCornerRadius: Dp = 6.dp
) {

	var composableSize by remember { mutableStateOf(IntSize(0, 0)) }
	val height = barHeight
	val barWidth = remember(key1 = composableSize) { composableSize.width.toFloat() }
	val barXStart = 0f
	val barYStart = 0f

	Canvas(
		modifier = modifier
			.height(height)
			.onSizeChanged {
				composableSize = it
			}
	) {
		drawRoundRect(
			color = barColor,
			topLeft = Offset(barXStart, barYStart),
			size = Size(barWidth, barHeight.toPx()),
			cornerRadius = CornerRadius(barCornerRadius.toPx(), barCornerRadius.toPx())
		)
	}
}
```

We already prepared some variables, like **height**, **barWidth**, **barXStart** and **barYStart**. We will need them later to calculate a better positioning. The **height** we put into **Modifier.height** of our canvas and we use the best practice of allowing to pass a **Modifier**, so the width can be determined by the caller.

> Interesting to note: We made **barWidth** recalculation dependent on the size of the Composable. Since as long as the size of the Composable does not change we can just **remember** **barWidth**.

The result so far looks like this
![Rounded rect bar](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/c7njis44w03zlyn7tpf9.png)

What we also can see with this small snippet is that we already have the need for some configuration and the need to convert Dp to pixels with **toPx** for drawing is given.

What we can do to clean this up a little is, like we saw in [part 3](https://dev.to/lex_fury/context-receivers-39p5), introduce a configuration data class.

```Kotlin
data class SliderConfig(
	val barHeight: Dp = 12.dp,
	val barColor: Color = Color.LightGray,
	val barCornerRadius: Dp = 6.dp
) {
	context(Density)
	val barHeightPx: Float
		get() = barHeight.toPx()

	context(Density)
	val barCornerRadiusPx
		get() = barCornerRadius.toPx()
}
```

We moved our config into **SliderConfig** and with that we can use Context Receivers to encapsulate the conversion to pixels directly in this class. This time we use **Density** as a **Context**, because it is implement by **DrawScope**, but can be used more general. Why? We will see in a bit :-).

### Slider handle (_Orange_)
Next let's add the slider handles. As we can see in the GIF above, the handle has a shadow around it and it also reacts to the touch, by increasing the shadow size. 
Without the shadow we could just simply call **drawCircle** and be done. Unfortunately we can't apply a shadow effect easily with the regular **drawCircle** function of a canvas. But luckily we can use **drawIntoCanvas** and its **drawCircle** function. It allows us to provide a **Paint** parameter, with which we can implement our shadow.

```Kotlin
private fun DrawScope.drawCircleWithShadow(
	position: Offset,
	touched: Boolean,
	sliderConfig: SliderConfig
) {
	val touchAddition = if (touched) {
		sliderConfig.touchCircleShadowTouchedSizeAdditionPx
	} else {
		0f
	}

	drawIntoCanvas {
		val paint = androidx.compose.ui.graphics.Paint()
		val frameworkPaint = paint.asFrameworkPaint()
		frameworkPaint.color = sliderConfig.touchCircleColor.toArgb()
		frameworkPaint.setShadowLayer(
			sliderConfig.touchCircleShadowSizePx + touchAddition,
			0f,
			0f,
			Color.DarkGray.toArgb()
		)
		it.drawCircle(
			position,
			sliderConfig.touchCircleRadiusPx,
			paint
		)
	}
}
```

As we can see, we create a **Paint** object and convert it to **NativePaint**. This gives us access to **setShadowLayer**. We give the shadow a size, depending on it the circle is touched or not and draw our circle with it.
We also added a little bit more configuration into our **SliderConfig** class.

With the circle function ready we need to update the calculation of our bar. When the handle is add the end of the bar we don't want it to overlap our Composable or worse go off-screen. Therefore we need to add a little padding to our bar.

For that we now need to access pixel values of our configuration and as we saw above for that we need to be within the **Scope** of **Density** object. One way to solve this would be to move the calculation into the **onDraw** lambda of our canvas. This would mean recalculate these values every time we do a draw, but they only need to be updated if either the **Density** or the size of our Composable changes. 
What we can do is create a small extension function on the size and the **Density**.

```Kotlin
@Composable
private fun <T> Pair<IntSize, Density>.derive(additionalKey: Any? = null, block: Density.() -> T): T =
	remember(key1 = first, key2 = additionalKey) {
		second.block()
	}
```

And with that we can write our size calculations like this:

```Kotlin
val currentDensity = LocalDensity.current
val sizeAndDensity = composableSize to currentDensity

val barYCenter = sizeAndDensity.derive { (height / 2).toPx() }
val barXStart = sizeAndDensity.derive { sliderConfig.touchCircleRadiusPx }
val barYStart = sizeAndDensity.derive { barYCenter - sliderConfig.barHeightPx / 2f }
val barWidth = sizeAndDensity.derive { composableSize.width - 2 * barXStart }
```

The important piece here is that we always can get the current **Density** with **LocalDensity.current** within a Composable.

Let's position our handles at the start and end of the bar for now and draw them.

```Kotlin
val leftCirclePosition = remember(key1 = composableSize) {
	Offset(barXStart, barYCenter)
}
val rightCirclePosition = remember(key1 = composableSize) {
	Offset(barXStart + barWidth, barYCenter)
}

...
// in our Canvas
		drawCircleWithShadow(
			leftCirclePosition,
			false,
			sliderConfig
		)

		drawCircleWithShadow(
			rightCirclePosition,
			false,
			sliderConfig
		)
```

The result up until now looks like this:
![Bar and touch handles](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/h407wk5z6tl0tsvyboeb.png)
 
### Labels and step markers (_Red_ and _Green_)
Next we want to draw the labels above the bar as well as the step markers. It makes sense to look at them together, because a label and its step marker should be aligned correctly. What we already know is positioning on the y-axis for our labels and the step markers. The labels should be at the top of our Composable and the step markers aligned with the middle of our bar. What we still need is the positioning on the x-axis for the single steps. For that we first of allow to pass steps into our Composable and create a small function to calculate the x coordinates.

```Kotlin
private fun calculateStepCoordinatesAndSpacing(
	numberOfSteps: Int,
	barXStart: Float,
	barWidth: Float,
	stepMarkerRadius: Float,
): Pair<FloatArray, Float> {
	val stepOffset = barXStart + stepMarkerRadius
	val stepSpacing = (barWidth - 2 * stepMarkerRadius) / (numberOfSteps - 1)

	val stepXCoordinates = generateSequence(stepOffset) { it + stepSpacing }
		.take(numberOfSteps)
		.toList()

	return stepXCoordinates.toFloatArray() to stepSpacing
}
```

We calculate the start to be aligned with the start of our bar and depending on the amount of steps we have, we calculate the spacing between them.

Since this calculation is not only dependent on Composable size and **Density**, but also on the amount of steps, we use our **derive** function to perform it.

```Kotlin
val (stepXCoordinates, stepSpacing) = sizeAndDensity.derive(steps) {
		calculateStepCoordinatesAndSpacing(
			numberOfSteps = steps.size,
			barXStart = barXStart,
			barWidth = barWidth,
			stepMarkerRadius = sliderConfig.stepMarkerRadiusPx
		)
	}
```

Additionally we provide the steps as a second key to the **remember** function. This way we can ensure if the steps are changing we can update our Composable.

After we calculated the positions we can draw our labels and step markers.

```Kotlin
private fun <T> DrawScope.drawStepMarkersAndLabels(
	steps: List<T>,
	stepXCoordinates: FloatArray,
	leftCirclePosition: Offset,
	rightCirclePosition: Offset,
	barYCenter: Float,
	sliderConfig: SliderConfig
) {
	assert(steps.size == stepXCoordinates.size) { "Step value size and step coordinate size do not match. Value size: ${steps.size}, Coordinate size: ${stepXCoordinates.size}" }

	steps.forEachIndexed { index, step ->
		val stepMarkerCenter = Offset(stepXCoordinates[index], barYCenter)

		val isCurrentlySelectedByLeftCircle =
			(leftCirclePosition.x > (stepMarkerCenter.x - sliderConfig.stepMarkerRadiusPx / 2)) &&
					(leftCirclePosition.x < (stepMarkerCenter.x + sliderConfig.stepMarkerRadiusPx / 2))
		val isCurrentlySelectedByRightCircle =
			(rightCirclePosition.x > (stepMarkerCenter.x - sliderConfig.stepMarkerRadiusPx / 2)) &&
					(rightCirclePosition.x < (stepMarkerCenter.x + sliderConfig.stepMarkerRadiusPx / 2))

		val paint = when {
			isCurrentlySelectedByLeftCircle || isCurrentlySelectedByRightCircle                     -> sliderConfig.textSelectedPaint
			stepMarkerCenter.x < leftCirclePosition.x || stepMarkerCenter.x > rightCirclePosition.x -> sliderConfig.textOutOfRangePaint
			else                                                                                    -> sliderConfig.textInRangePaint
		}

		drawCircle(
			color = sliderConfig.stepMarkerColor,
			radius = sliderConfig.stepMarkerRadiusPx,
			alpha = .1f,
			center = stepMarkerCenter
		)

		drawIntoCanvas {
			val stepText = step.toString().let { text ->
				if (text.length > 3) {
					text.substring(0, 2)
				} else {
					text
				}
			}
			it.nativeCanvas.drawText(
				stepText,
				stepMarkerCenter.x - (stepText.length * sliderConfig.textSizePx) / 3,
				sliderConfig.textSizePx,
				paint
			)
		}
	}
}
```
We pass in the calculated x-axis positions and the steps to iterate over them and position the step marker and the label accordingly. As you can see in the **drawIntoCanvas** function we are accessing the native canvas to draw our label, since the normally canvas does not have a function for drawing text.
Depending on the position of the two handles, we select a different paint, so that labels reflect the selected range in our slider as well.

We added more properties to our **SliderConfig** to control the colors, the text size, the text offset and the color of the step markers. With the additional sizes we can update the height calculation of our Composable.

```Kotlin
val height = remember(key1 = sliderConfig) { sliderConfig.touchCircleRadius * 2 + sliderConfig.textSize.value.dp + sliderConfig.textOffset }
```

As well as the calculation of our positioning variables.

```Kotlin
val barYCenter = sizeAndDensity.derive { composableSize.height - sliderConfig.touchCircleRadiusPx }
val barXStart = sizeAndDensity.derive { sliderConfig.touchCircleRadiusPx - sliderConfig.stepMarkerRadiusPx }
val barYStart = sizeAndDensity.derive { barYCenter - sliderConfig.barHeightPx / 2 }
val barWidth = sizeAndDensity.derive { composableSize.width - 2 * barXStart }
val barCornerRadius = sizeAndDensity.derive { CornerRadius(sliderConfig.barCornerRadiusPx, sliderConfig.barCornerRadiusPx) }
```

We put the **drawStepMarkersAndLabels** in our canvas below the **drawRoundRect**, but above the functions to draw our markers. The result looks like this:

![Bar with handles, labels and step markers](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5rkyvx6fviwxh3w4zx6z.png)
 
### Finalizing the UI (_Blue_)
As we can see we are almost done with drawing the UI. What's still missing is the indication of the selected range on the bar and to position our handles correctly to the currently selected value.

First we position our handles. For that we want our Composable to be able to receive these values from the caller, since we don't want to manage this kind of state.

```Kotlin
@Composable
fun <T : Number> LabeledRangeSlider(
	selectedLowerBound: T,
	selectedUpperBound: T,
	steps: List<T>,
	modifier: Modifier = Modifier,
	sliderConfig: SliderConfig = SliderConfig()
) {
...
}
```

With these two values we can update the positioning of our handles

```Kotlin
var leftCirclePosition by remember(key1 = composableSize) {
	val lowerBoundIdx = steps.indexOf(selectedLowerBound)
	mutableStateOf(Offset(stepXCoordinates[lowerBoundIdx], barYCenter))
}
var rightCirclePosition by remember(key1 = composableSize) {
	val upperBoundIdx = steps.indexOf(selectedUpperBound)
	mutableStateOf(Offset(stepXCoordinates[upperBoundIdx], barYCenter))
}
```

![Handles positioned to the step marker](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/e9ui3s2er7m5xfrtthxb.png)

Now the label of the selected step is correctly drawn with a bold font style.

The last step to complete the drawing of the UI, is to add a **drawRect** function below drawing the bar background with **drawRoundRect**.

```Kotlin
drawRect(
	color = sliderConfig.barColorInRange,
	topLeft = Offset(leftCirclePosition.x, barYStart),
	size = Size(rightCirclePosition.x - leftCirclePosition.x, sliderConfig.barHeightPx)
)
```

To see the result better we set our **selectedLowerBound** and **selectedUpperBound** to 10 and 90 respectively. 

![Labeled range slider fully drawn](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dog0152cveb2u56kl7mf.png)

Looks like we finished the drawing part of our Labeled Range Slider :-).
 

## Make it interactive
We are drawing everything we need for our Labeled Range Slider. Now we need to make it interactive. While writing this post I realized it is already pretty long, that's why I decided to split up this part into another post. 

[Let's jump right into it](https://dev.to/lex_fury/make-the-labeled-range-slider-interactive-5gf) or visit [GitHub](https://github.com/a-frank/labeled-range-slider) to explore the full source code.