Subscribe for UI recipes
Centered Slider in Jetpack Compose
Creating a glowy centered slider based on the material 3 slider
In the Material 3 docs, there are specifications and guidelines for a Centered Slider. This can be used for sliders that have their origin somewhere in the middle, and the user can adjust towards positive or negative values. For example, in a photo editor, we can have a contrast slider that starts off at 0. The user can then increase or decrease the contrast to their liking.
Only one problem.
I can’t find a Centered Slider anywhere in the Jetpack Compose material 3 library.
So let’s build one!
CenteredSlider
We will create a customizable Composable for our centered slider based on the one provided by material 3 library.
With this Composable, we will be able to create various slider designs.
Parameters
@Composable
fun CenteredSlider(
value: Float,
onValueChanged: (Float) -> Unit,
modifier: Modifier = Modifier,
valueRange: ClosedFloatingPointRange<Float> = -1f..1f,
thumb: @Composable () -> Unit = DefaultThumb,
center: Float = 0f,
centerThreshold: Float = .05f,
centerIndicator: @Composable () -> Unit = DefaultCenterIndicator,
centerTrack: @Composable (@Composable (@Composable (Boolean) -> Unit) -> Unit) -> Unit = DefaultTrack,
) { ... }
Above will be our function signature of our CenteredSlider. The first few parameters (value
, onValueChanged
, modifier
, valueRange
, thumb
) remain unchanged from the original slider.
The first new parameter is center
. This will indicate where within the valueRange
, will be our center.
Material 3 docs say that the starting value should be at the center. So I guess use with caution ;)
For this article, I just wanted to demonstrate how this can be tweaked for your needs.
Next, we have a centerThreshold
. This is to snap the slider back to the center
, whenever the value
gets closer than the specified threshold. Scale number larger according to the range of your slider, or set it to 0, to turn off snapping.
centerIndicator
simply allows us to define some UI element to indicate the center of the slider.
Last (and definitely not least), we have the centerTrack
. This looks vastly different from the track
parameter that we have on the original slider. The extra Composable parameters are there so that we can use it to create distinct active and inactive sections.
To illustrate this better, let's look at an example of how we will eventually be able to use centerTrack
.
centerTrack = { activeSection ->
// Inactive part of the slider
Box(
Modifier
.fillMaxWidth()
.height(2.dp)
.background(Color.LightGray))
activeSection { isAboveCenter ->
// Active part of the slider
Box(
Modifier
.fillMaxWidth()
.height(4.dp)
.background(
color = if (isAboveCenter) Color.Green else Color.Red,
shape = CircleShape
)
)
}
}
We receive activeSection
composable that defines the bounds of the active section of the slider. But first, let's render the inactive section, which is just a thin gray line.
Then we call activeSection
in which we render a thicker line. We can make this line green or red, depending on the slider's value being above the center.
This will give us this simple slider design.
Implementation
Since we are extending the default slider, let's first add it into our CenteredSlider
function body.
Slider(
value = value,
onValueChange = {
onValueChanged(
when {
(it - center).absoluteValue < centerThreshold -> center
else -> it
}
)
},
modifier = modifier,
valueRange = valueRange,
thumb = {
Box(contentAlignment = Alignment.Center) {
thumb()
}
},
...
)
This looks like the normal usage of a Slider, with some modifications. Notably, inside onValueChanged
, we are implementing snapping to the center, based on the defined centerThreshold
. So if our new value is close enough to the center
, we will just pass in center
.
For some extra UX points, you can add haptic feedback when this snapping happens. This gives the user some tactile feedback and makes the snapping feel intentional.
val hapticFeedback = LocalHapticFeedback.current
val valueState = rememberUpdatedState(value)
LaunchedEffect(Unit) {
snapshotFlow { valueState.value }
.map { it == center }
.filter { it }
.drop(if (value == center) 1 else 0)
.collect {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
}
We can achieve this with a LaunchedEffect
that collects valueState
and vibrates the device whenever its value is exactly center
. Before this, we drop the first occurrence of this, in case value
starts off the same as center
. This is to avoid vibrating the device, immediately when the Slider is rendered.
Next, we can define track
, which will hold our centerTrack
and centerIndicator
.
track = { sliderState ->
val fraction by remember {
derivedStateOf {
(sliderState.value - sliderState.valueRange.start) / (sliderState.valueRange.endInclusive - sliderState.valueRange.start)
}
}
val centerFraction by remember {
derivedStateOf {
(center - sliderState.valueRange.start) / (sliderState.valueRange.endInclusive - sliderState.valueRange.start)
}
}
...
}
}
We first calculate the current position of the slider and the defined center, which we both normalize to be within 0f..1f
based on the slider's range. These two values will help us in placing the active section of the slider and the center indicator.
BoxWithConstraints(
contentAlignment = Alignment.Center
) {
val isAboveCenter = fraction > centerFraction
val width = this@BoxWithConstraints.maxWidth
centerTrack { activeSection ->
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.offset {
when {
isAboveCenter -> IntOffset(
x = (width.toPx() * centerFraction).roundToInt(),
y = 0
)
else -> IntOffset(
x = (width.toPx() * fraction).roundToInt(),
y = 0
)
}
}
.width(
when {
isAboveCenter -> width * (fraction - centerFraction)
else -> width * (centerFraction - fraction)
}
)
.height(20.dp),
contentAlignment = Alignment.Center,
content = { activeSection(isAboveCenter) }
)
}
...
}
After calculating the fractions needed, we can create a BoxWithConstraints
to contain our track. We do this so that we can access the element's width.
Using the width, we can calculate the offset of the active section of our slider. If the value is above the center, we will offset it so that the origin is at the center. If not, the origin will move along the slider, according to the current value.
Likewise, we will adjust the active section's width as well based on if the value is above the center. If true, we will set it to a fraction of the full width based on the current value, minus the length to the center.
Otherwise, we set it to the length between the center and the current value.
BoxWithConstraints(
contentAlignment = Alignment.Center
) {
...
Box(
Modifier
.align(Alignment.CenterStart)
.offset {
IntOffset(
x = (width.toPx() * centerFraction).roundToInt(),
y = 0
)
}
.centerHorizontally(),
content = { centerIndicator() }
)
}
Finally we add the center indicator. This will have an offset in order for its origin to at our center. Then it will moved back a little, so that it's visually centered.
Usage
Now that we have our customizable CenteredSlider
we can build whatever slider design we desire.
Let's create the glowy slider from the intro.
var value by remember { mutableFloatStateOf(5f) }
CenteredSlider(
value = value,
onValueChanged = { value = it },
valueRange = -20f..20f,
center = 0f,
centerThreshold = 1f,
...
)
We begin by setting up some basic values. Remember, we need to set the centerThreshold
relative to the magnitude of our valueRange
.
thumb = {
Box(
Modifier
.size(24.dp)
.border(
width = Dp.Hairline,
color = MaterialTheme.colorScheme.onSurface,
shape = CircleShape
)
)
Box(
Modifier
.offset(y = (-24).dp)
.width((.4).dp)
.height(16.dp)
.background(MaterialTheme.colorScheme.onSurface)
)
Text(
"${value.roundToInt()}",
modifier = Modifier.offset(y = (-42).dp),
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Normal,
fontSize = 10.sp
),
color = MaterialTheme.colorScheme.onSurface,
)
},
Next, we create our thumb
. This will just be a circular border, a number indicating the current value and a thin line between the two.
centerIndicator = {
Box(
Modifier
.width(6.dp)
.height(16.dp)
.background(color = MaterialTheme.colorScheme.surface, CircleShape)
.padding(1.dp)
.shadow(elevation = 10.dp, shape = CircleShape)
.background(color = MaterialTheme.colorScheme.onSurface, CircleShape)
)
},
For the centerIndicator
, we will just have a pill shaped, white Box
with a shadow.
centerTrack = { activeSection ->
Box(
Modifier
.fillMaxWidth()
.height(1.dp)
.background(color = MaterialTheme.colorScheme.onSurface.copy(alpha = .5f))
)
activeSection { isAboveCenter ->
val brush = remember {
Brush.horizontalGradient(
when {
isAboveCenter -> listOf(Color(0xFF38BDF8), Color(0xFF34D399))
else -> listOf(Color(0xFFEF4444), Color(0xFFEC4899))
}
)
}
Box(
Modifier
.fillMaxWidth()
.height(8.dp)
.scale(scaleX = 1.3f, scaleY = 1f)
.blur(30.dp, BlurredEdgeTreatment.Unbounded)
.background(
brush = brush,
shape = CircleShape
)
)
Box(
Modifier
.fillMaxWidth()
.height(8.dp)
.background(
brush = brush,
shape = CircleShape
)
) {
Box(
Modifier
.align(if (!isAboveCenter) Alignment.CenterStart else Alignment.CenterEnd)
.size(8.dp)
.padding(2.dp)
.background(color = Color.Black, CircleShape)
)
}
}
}
Finally, we have the centerTrack
. We will first add a think gray line indicating the inactive section of the slider.
Above that, we will render the active section.
This will be a thicker Box
with a gradient applied. We will use a different gradient, depending on if our current value is positive or negative.
We will draw this Box
twice, but blur and scale the one below, in order to achieve a glowing effect.
And with that, we have a beautiful Centered Slider.
Check out the code here to build your own.
Thanks for reading and good luck!