DEV Community

Cover image for Draw the Labeled Range Slider
Alex
Alex

Posted on

Draw the Labeled Range Slider

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

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

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.

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

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

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, introduce a configuration data class.

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

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.

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

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.

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

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

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

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.

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

The result up until now looks like this:
Bar and touch handles

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.

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

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.

val (stepXCoordinates, stepSpacing) = sizeAndDensity.derive(steps) {
        calculateStepCoordinatesAndSpacing(
            numberOfSteps = steps.size,
            barXStart = barXStart,
            barWidth = barWidth,
            stepMarkerRadius = sliderConfig.stepMarkerRadiusPx
        )
    }
Enter fullscreen mode Exit fullscreen mode

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.

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

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.

val height = remember(key1 = sliderConfig) { sliderConfig.touchCircleRadius * 2 + sliderConfig.textSize.value.dp + sliderConfig.textOffset }
Enter fullscreen mode Exit fullscreen mode

As well as the calculation of our positioning variables.

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

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

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.

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

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

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

Handles positioned to the step marker

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.

drawRect(
    color = sliderConfig.barColorInRange,
    topLeft = Offset(leftCirclePosition.x, barYStart),
    size = Size(rightCirclePosition.x - leftCirclePosition.x, sliderConfig.barHeightPx)
)
Enter fullscreen mode Exit fullscreen mode

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

Labeled range slider fully drawn

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 or visit GitHub to explore the full source code.

Top comments (0)