Subscribe for UI recipes

Custom Sliders in Jetpack Compose

Create unique and delightful sliders for your app

Custom Sliders in Jetpack Compose

A slider is a UI element that enables the user to select a value within a specified range. The Material 3 documentation and its library provide guidance on implementing them. But by default, you will get the same slider as every other app with Material Design.


What if you want something a little more exciting like this?

Final slider animation

Luckily, the Material 3 library for Jetpack Compose can be easily extended to customize sliders however we want.

Basics

First, let's implement a simple custom design for a slider using the Material 3 library.

var value by remember { mutableFloatStateOf(.5f) }  
  
Slider(  
	value = value,  
	onValueChange = { value = it },
)

This code, will give us a functioning default slider that will inherit all the Material 3 colors defined.

Default Material 3 slider

Slider takes in two other optional arguments that we can implement to make it look however we want. These are thumb and track and they can take in any Composables and render them in their respective positions.

For example, we can replace the circular thumb of the slider with a diamond shape, like this:

Slider(  
    value = value,  
    onValueChange = { value = it },  
    thumb = {  
        Box(  
            Modifier  
                .size(48.dp)  
                .padding(4.dp)  
                .background(Color.White, CutCornerShape(20.dp))  
        )  
    },
    ...
)

And then, we can modify the track like this:

Slider(  
    ...
      
    track = { sliderState ->  

		// Calculate fraction of the slider that is active
        val fraction by remember {  
            derivedStateOf {  
                (sliderState.value - sliderState.valueRange.start) / (sliderState.valueRange.endInclusive - sliderState.valueRange.start)  
            }  
        }   
             
        Box(Modifier.fillMaxWidth()) {  
		    Box(  
		        Modifier  
		            .fillMaxWidth(fraction)  
		            .align(Alignment.CenterStart)  
		            .height(6.dp)  
		            .padding(end = 16.dp)  
		            .background(Color.Yellow, CircleShape)  
		    )  
		    Box(  
		        Modifier  
		            .fillMaxWidth(1f - fraction)  
		            .align(Alignment.CenterEnd)  
		            .height(1.dp)  
		            .padding(start = 16.dp)  
		            .background(Color.White, CircleShape)  
		    )  
		} 
    }
)

For the track, we first need the fraction amount of the slider that is active. We could use the value variable that we made earlier, but this would fail in a more complex slider with a custom range (not 0f..1f).


Therefore we need to take into account the start and end ranges of the slider, in order to calculate an accurate fraction for all situations.


Next, we can use this fraction in whatever UI elements we choose to display this information. Here, I am simply using two boxes that I have supplied fraction in both their fillMaxWidth modifiers. The first Box, is thicker and yellow, while the second is thinner and white. These are to represent the active and inactive sections respectively.

Simple custom slider

Now that we have covered the basics, let's move on to a more complex example.

Line Slider

Setup

Let's create a Composable that will hold our LineSlider for easier reusability.

@OptIn(ExperimentalMaterial3Api::class)  
@Composable  
fun LineSlider(  
    value: Float,  
    onValueChange: (Float) -> Unit,  
    modifier: Modifier = Modifier,  
    steps: Int = 0,  
    valueRange: ClosedFloatingPointRange<Float> = 0f..1f,  
    thumbDisplay: (Float) -> String = { "" },  
) {
	// Implementation goes here
}

The first two, value and onValueChange, we have seen earlier. They are not optional since they are integral to a working Slider.


After the Modifier, we have steps which defines the amount of snapping points along the slider. 0 gives as a smooth slider with no snapping. We will use this value later on to render some graduations along our slider.


valueRange changes the range of values the user can use the slider to select.
The parameters explained above will be passed in to the Material 3 Slider later on. But the thumbDisplay will be used by us to render text on the slider thumb. We are passing it in as a function of the current value so that the caller of LineSlider can format the text to their liking.

Now let's create our slider.

val animatedValue by animateFloatAsState(  
    targetValue = value,  
    animationSpec = spring(  
        dampingRatio = Spring.DampingRatioLowBouncy  
    ), label = "animatedValue"  
)  
  
Slider(  
    value = animatedValue,  
    onValueChange = onValueChange,  
    modifier = modifier,  
    valueRange = valueRange,  
    steps = steps,
)

We pass in all the parameters as arguments, except value. For this, we create an animation using animateFloatAsState(). This will give us a smooth animation when the user taps the slider, or starts sliding from a point that is not close to the current slider position.


In fact, if you run it now, you can see that the default slider will have an animation on the thumb, as you tap from point to point.

Default slider with an animated value

Thumb

For the thumb, we want a circle that raises to a certain height when the user is dragging the slider, and then back down when the user releases it. Inside the circle, we shall render the text defined by thumbDisplay.


...

val interaction = remember { MutableInteractionSource() }  
val isDragging by interaction.collectIsDraggedAsState()  
val density = LocalDensity.current  
val offsetHeight by animateFloatAsState(  
    targetValue = with(density) { if (isDragging) 36.dp.toPx() else 0.dp.toPx() },  
    animationSpec = spring(  
        stiffness = Spring.StiffnessMediumLow,  
        dampingRatio = Spring.DampingRatioLowBouncy  
    ), label = "offsetAnimation"  
)  
  
Slider(  
    ...  
    interactionSource = interaction,
)

To know when a user is dragging the slider, we need to create a MutableInteractionSource and pass that into the Slider. We can then use it to collect the boolean state indicating if the user is dragging the slider.

We can finally use isDragging to animate a float value to a certain height. This offsetHeight value is what we shall use for raising the thumb.

Ahh, the thumb. We need to talk about that.


Earlier, I said that we pass in the thumb as Composable function to the Slider. But that's not exactly what I did for this example. The Material 3 slider takes whatever is in the thumb parameter and positions it according to the position of the slider. Unfortunately, this positioning discards the horizontal animation, that we created in the last section, when steps is more than 0.


This may not be a problem with other slider designs. But for ours, we will, in a later section, add a curved line that animates smoothly. Our slider will then look too janky if our thumb is jittering around out of place.


Because of this, we shall pass in an empty lambda for thumb.

Slider(  
    ... 
    thumb = {},
)

The thumb code, shall go into track

Slider(  
    ...
    track = { sliderState ->  
  
        val fraction by remember {  
            derivedStateOf {  
                (animatedValue - sliderState.valueRange.start) / (sliderState.valueRange.endInclusive - sliderState.valueRange.start)  
            }  
        }
    
        ...
    }
)

Inside track we first calculate the fraction of the slider that is active.

track = { sliderState -> 
	var width by remember { mutableIntStateOf(0) }  
	  
	Box(  
	    Modifier  
	        .clearAndSetSemantics { }  
	        .height(thumbSize)  
	        .fillMaxWidth()  
	        .onSizeChanged { width = it.width },  
	) {
		// Thumb and track UI code will go here
	}
}

Next, we will create a Box that fills the entire space and we grab the width in pixels. Remember that we lost the convenience of the Slider automatically calculating the position of the thumb. So we need to do it ourselves using the full width.

clearAndSetSemantics { } is used to cancel out any screen reader from highlighting this section. Without this, any user with Talkbalk enabled could select the text inside the thumb. Instead, let's let the Slider handle the accessibility.

Box(  
    Modifier  
        .zIndex(10f)  
        .align(Alignment.CenterStart)  
        .offset {  
            IntOffset(  
                x = lerp(  
                    start = -(thumbSize / 2).toPx(),  
                    end = width - (thumbSize / 2).toPx(),  
                    t = fraction  
                ).roundToInt(),  
                y = -offsetHeight.roundToInt(),  
            )  
        }
        ...

Here we are defining the positioning of the thumb. We want it above other elements so we give it a high zIndex. Then we align it to CenterStart.


Now it's time to use the width and the fraction. Based on fraction, we will offset the Box on the x-axis.

On the y-axis, we will use the offsetHeight from earlier, which will raise or lower the thumb if the user is dragging it.

Box(  
    Modifier  
        ... 
        .size(thumbSize)  
        .padding(10.dp)  
        .shadow(  
            elevation = 10.dp,  
            shape = CircleShape,  
        )  
        .background(  
            color = Color(0xFFECB91F),  
            shape = CircleShape  
        ),  
    contentAlignment = Alignment.Center,  
) {  
    Text(  
        thumbDisplay(animatedValue),  
        style = MaterialTheme.typography.labelSmall,  
        color = Color.Black  
    )  
}

Finally, we define the appearance of the thumb itself. We'll give it a circular shape with a yellow color.


Inside it, we will render the text.

Only the thumb is animated. Track is not visible

Track

Our track will be a line that runs from end to end of the slider. But when the user drags on the slider, The line transforms into a curve, with the topmost point on the position of the raised thumb. If steps is more than 0, there will be some graduations along the track.

Right underneath the thumb, we will add the code for our track.

val strokeColor = MaterialTheme.colorScheme.onSurface  
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr  
Box(  
    Modifier  
        .align(Alignment.Center)  
        .fillMaxWidth()  
        .drawWithCache {  
            onDrawBehind {  
                scale(  
                    scaleY = 1f,  
                    scaleX = if (isLtr) 1f else -1f  
                ) {  
                    drawSliderPath(  
                        fraction = fraction,  
                        offsetHeight = offsetHeight,  
                        color = strokeColor,  
                        steps = sliderState.steps  
                    )  
                }  
            }        
        }
)

In this code, we are collecting the necessary arguments to pass into drawSliderPath(), which will draw our track.


But before we draw it, we will flip it using scale() if isLtr is false. This is to account for right-to-left languages, where the slider would be running from right to left instead.

The Material 3 slider handles RTL well, but because we are doing some custom drawing for the track, we would need to take care of it ourselves here.

On to the definition of drawSliderPath().

fun DrawScope.drawSliderPath(  
    fraction: Float,  
    offsetHeight: Float,  
    color: Color,  
    steps: Int,  
) {
	val path = Path()  
	val activeWidth = size.width * fraction  
	val midPointHeight = size.height / 2  
	val curveHeight = midPointHeight - offsetHeight  
	val beyondBounds = size.width * 2  
	val ramp = 72.dp.toPx()

	...
	
}

After creating the path, we will set up some useful measurements that we will use.

// Point far beyond the right edge  
path.moveTo(  
    x = beyondBounds,  
    y = midPointHeight  
)  
  
// Line to the "base" right before the curve  
path.lineTo(  
    x = activeWidth + ramp,  
    y = midPointHeight  
)  
  
// Smooth curve to the top of the curve  
path.cubicTo(  
    x1 = activeWidth + (ramp / 2),  
    y1 = midPointHeight,  
    x2 = activeWidth + (ramp / 2),  
    y2 = curveHeight,  
    x3 = activeWidth,  
    y3 = curveHeight,  
)  
  
// Smooth curve down the curve to the "base" on the other side  
path.cubicTo(  
    x1 = activeWidth - (ramp / 2),  
    y1 = curveHeight,  
    x2 = activeWidth - (ramp / 2),  
    y2 = midPointHeight,  
    x3 = activeWidth - ramp,  
    y3 = midPointHeight  
)  
  
// Line to a point far beyond the left edge  
path.lineTo(  
    x = -beyondBounds,  
    y = midPointHeight  
)

Here, we have defined the path of the track, from right to left. The path is running to and from points that are beyond the bounds of our track. Later, we will trim it and get a nice curve when the user drags towards the edges of the track.


Unfortunately, the PathOperation that we will use for trimming the path does not work as intended with an open path. And if we close our path now, we will lose our thin curved line and instead have a boa constrictor digesting an elephant.


Therefore we need to close the path manually along itself.

val variation = .1f  
  
// Line to a point far beyond the left edge  
path.lineTo(  
    x = -beyondBounds,  
    y = midPointHeight + variation  
)  
  
// Line to the "base" right before the curve  
path.lineTo(  
    x = activeWidth - ramp,  
    y = midPointHeight + variation  
)  
  
// Smooth curve to the top of the curve  
path.cubicTo(  
    x1 = activeWidth - (ramp / 2),  
    y1 = midPointHeight + variation,  
    x2 = activeWidth - (ramp / 2),  
    y2 = curveHeight + variation,  
    x3 = activeWidth,  
    y3 = curveHeight + variation,  
)  
  
// Smooth curve down the curve to the "base" on the other side  
path.cubicTo(  
    x1 = activeWidth + (ramp / 2),  
    y1 = curveHeight + variation,  
    x2 = activeWidth + (ramp / 2),  
    y2 = midPointHeight + variation,  
    x3 = activeWidth + ramp,  
    y3 = midPointHeight + variation,  
)  
  
// Line to a point far beyond the right edge  
path.lineTo(  
    x = beyondBounds,  
    y = midPointHeight + variation  
)

Here is the same line, but in reverse, plus some variation on the y-axis.
This was the best solution I could come up with. But I don't love it. I am open to any ideas down in the comments on how to improve this.

Next, let's trim the path.

val exclude = Path().apply {  
    addRect(Rect(-beyondBounds, -beyondBounds, 0f, beyondBounds))  
    addRect(Rect(size.width, -beyondBounds, beyondBounds, beyondBounds))  
}  
  
val trimmedPath = Path()  
trimmedPath.op(path, exclude, PathOperation.Difference)

We first define the path to exclude, which is any point on the left or right, beyond the bounds of the UI element. Using this exclude path, we can then create a new path that is trimmed using PathOperation.Difference.

Before drawing the track, we'll draw the graduations along the path.

val pathMeasure = PathMeasure()  
pathMeasure.setPath(trimmedPath, false)

val graduations = steps + 1  
for (i in 0..graduations) {  
    val pos = pathMeasure.getPosition(
	    (i / graduations.toFloat()) * pathMeasure.length / 2
    ) 
    val height = 10f  
    when (i) {  
        0, graduations -> drawCircle(  
            color = color,  
            radius = 10f,  
            center = pos  
        )  
  
        else -> drawLine(  
            strokeWidth = if (pos.x < activeWidth) 4f else 2f,  
            color = color,  
            start = pos + Offset(0f, height),  
            end = pos + Offset(0f, -height),  
        )  
    }  
}

In order to draw them, we will need to know the length of our path to space out the graduations evenly. The number of graduations will be one more than steps to ensure that we always have a point at the start and end of the track.


Then we calculate position on the path of each graduation based on half the length (Remember, we have double the path).


With this position, we can draw a circle on the ends, and a lines along the track. Since the position also has the y-position of the path. Whatever we draw will also move up and down with the curve.

Graduations along the slider's width have been added

And finally we draw the track itself.

fun DrawScope.drawSliderPath(...) {
	...
	clipRect(  
	    left = -beyondBounds,  
	    top = -beyondBounds,  
	    bottom = beyondBounds,  
	    right = activeWidth,  
	) {  
	    drawTrimmedPath(trimmedPath, color)  
	}  
	clipRect(  
	    left = activeWidth,  
	    top = -beyondBounds,  
	    bottom = beyondBounds,  
	    right = beyondBounds,  
	) {  
	    drawTrimmedPath(trimmedPath, color.copy(alpha = .2f))  
	}
}

fun DrawScope.drawTrimmedPath(path: Path, color: Color) {  
    drawPath(  
        path = path,  
        color = color,  
        style = Stroke(  
            width = 10f,  
            cap = StrokeCap.Round,  
            join = StrokeJoin.Round,  
        ),  
    )  
}

We draw it twice (active & inactive) and clip it according to activeWidth. The active side will have the color provided but the inactive side will have the same color at 20% opacity.

Final slider animation

And with that, we have our unique slider, ready to use in your app. Feel free to play around with the code to come up with some whacky variations. If you have any questions or comments, don't hesitate to write them below. Source code is available here.

Thanks for reading and good luck!

Mastodon