Subscribe for UI recipes

Centered Slider in Jetpack Compose

Creating a glowy centered slider based on the material 3 slider

Centered Slider in Jetpack Compose

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.

0:00
/0:12

centered slider used to control the contrast of an image

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.

0:00
/0:07

simple slider with just a track that switches between green and red

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.

0:00
/0:09

final centered slider design with a glow 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!

Mastodon