Fun With Sliders and Compose
Jun 29, 2026 · 10 minute readcode
androidcompose
I noticed a pretty neat UI interaction in Ayah for iOS that I wanted to see if I could replicate in Android.
You’ll notice a few different things here:
- on pressing the thumb, the search and jump buttons disappear, and the track expands.
- when the track expands, the thumb sometimes moves accordingly (ex for 0% and 100% cases, the thumb moves to the start or end of the track, respectively).
- the page doesn’t move until the person’s finger starts to move.
I wanted to try the same thing in Compose, and decided to write this blog post documenting my experiences.
Material 3 Slider in a Row
The first attempt was to use a Material 3 slider within a Row:
┌────────┐───────────────────────────────────┌───────┐
│ │ │ │
│ │ │ │
└────────┘───────────────────────────────────└───────┘
b1 slider b2
We can easily test this using something like:
@Composable
fun RowBottomBar(
currentPage: Int,
onSetCurrentPage: (Int) -> Unit,
modifier: Modifier = Modifier
) {
val isInteracting = remember { mutableStateOf(false) }
Row(modifier = modifier.fillMaxWidth()) {
// AnimatedVisibility (Button 1), visible = !isInteracting.value
Slider(
value = currentPage.toFloat(),
onValueChange = { onSetCurrentPage(it.roundToInt()) },
valueRange = 1f.rangeTo(604f),
onValueChangeFinished = { isInteracting.value = false },
modifier = Modifier.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
isInteracting.value = true
}
}
.weight(1f)
)
// AnimatedVisibility (Button 2), visible = !isInteracting.value
}
}
The full code can be seen here. All we’re doing here is having a row with the buttons - we explicitly add a modifier.pointerInput block so that, upon the first down event (press), we set isInteracting to true, therefore hiding the buttons. We finally clear isInteracting in onValueChangeFinished to restore the buttons.
This works, but has one problem:
While this gets us the expansion behavior we want, there’s one clear issue - the thumb is always lagging behind the “pointer” (in this case, the mouse, but on a device, the person’s actual finger). Why is this the case?
Under the hood, Slider uses Modifier.draggable as the last portion of the Modifier on the SliderImpl. The draggable modifier returns deltas - how much we moved in terms of pixels relative to where we were (instead of absolute positions). Upon pressing down, the buttons are still showing, so the down event is on the Slider at its “collapsed” position. The Slider records a press offset based on the old coordinate system (before expansion of the Slider).
Then, when movement happens, the drag gives us a delta of the motion, which is applied to a raw offset and press offset that was set up before expanding the slider (i.e. it is relative to the old coordinate system). This ends up being the core problem - we’re adding a delta to the old coordinate system, when we’re in the new coordinate system at this point. If we think about the 0% or 100% case, when collapsed, the x might be buttonWidth or width - buttonWidth, but, when expanded, the x becomes 0 or width instead. Using the old x and adjusting it per drag motions will thus leave a gap between the actual finger and the thumb position.
(Note: if you’re curious about the code that actually causes this, check the usages of rawOffset within the dispatchRawDelta method, and the SliderState.onPress method, which sets the original pressOffset based on the down event).
Material 3 Slider in a Box
Given that changing the size of the component doesn’t update the M3 Slider’s internal in-progress gesture state (though it does re-layout and reflects the new width, it doesn’t recalculate the active drag offset), what if, instead, the Slider is always full width, and the buttons draw on top of it?
@Composable
fun BoxBottomBar(
currentPage: Int,
onSetCurrentPage: (Int) -> Unit,
modifier: Modifier = Modifier
) {
val isInteracting = remember { mutableStateOf(false) }
Box(modifier = modifier.fillMaxWidth()) {
Slider(
value = currentPage.toFloat(),
onValueChange = { onSetCurrentPage(it.roundToInt()) },
valueRange = 1f.rangeTo(604f),
onValueChangeFinished = { isInteracting.value = false },
modifier = Modifier.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
isInteracting.value = true
}
}
)
// AnimatedVisibility (Button 1), visible = !isInteracting.value
// AnimatedVisibility (Button 2), visible = !isInteracting.value
}
}
The full code is here. The code is very similar to the old one, but uses a Box instead of a Row, and draws the buttons above the Slider. This has one obvious problem, which is that the thumb will be hidden underneath the buttons when the percentages are close to 0% or 100%. Other than that, the effect pretty much works the way we want it to.
One obvious idea is that, since M3 allows us to set a thumb composable, we can pass one with a Modifier that offsets the thumb to start at buttonWidth (or end at width - buttonWidth). We can try this by adding a Thumb with an explicit offset like this:
val buttonWidthDp = 72.dp
val fraction = ((currentPage - 1f) / (604f - 1f)).coerceIn(0f, 1f)
val buttonWidthPx = with(LocalDensity.current) { buttonWidthDp.toPx() }
val thumbOffsetPx = lerp(buttonWidthPx, -buttonWidthPx, fraction)
Slider(
// ...
thumb = {
SliderDefaults.Thumb(
interactionSource = interactionSource,
colors = SliderDefaults.colors(),
enabled = true,
modifier = Modifier.offset {
if (!isInteracting) {
IntOffset(
thumbOffsetPx.roundToInt(),
0
)
} else {
IntOffset(0, 0)
}
}
)
}
)
The full code is here. What we’re essentially saying is that we’ll set the offset to be between buttonWidth and -buttonWidth based on the percentage - closer to the middle, we’re at a 0 offset, and closer to the sides, we’re at the +/- the button width.
So there’s clearly a mismatch here - we can see that there’s a gap between the thumb and progress (more notable on the left and right sides of the slider than in the middle). The middle is where the offset is closest to 0. (Note that there’s also a second bug, which is that we sometimes can’t go to the last page. With the recorded emulator width, the slider usually stops at around page 594, but can differ depending on the device width).
Given that, can we tweak the offset to make this more correct? Instead of using a linear interpolation, what if we do something like, “if the x is less than buttonWidth, set it to buttonWidth, and if it’s greater than width - buttonWidth, offset it by -buttonWidth”? The full code is here.
We’ll notice that here, we’re fairly close! In most cases, things work correctly. We fixed our first problem now, but our second problem remains, however - the maximum we can easily move the slider is to width - buttonWidth. The reason for this (and for the other problems we saw above) is that offset shifts the Thumb visually, but does not shift the Slider’s internal state of where the Thumb is. In the Slider, our thumb is between 0 and screenWidth. Even if we offset by buttonWidth at the start, we can move to 0 without a problem. At the end, however, screenWidth is the maximum for the seekbar, and when we’re at that width, we’ve shifted the Thumb by an offset of -buttonWidth.
In reality, because of our block setting the offset to 0 when we’re interacting and the proper offset otherwise, there are really two cases. The first is where we’re interacting (i.e. moving the slider) - in this case, we can reach the end easily. Given this, why is there a problem? The reality is that most of the time, as we approach the end of the screen, the gesture automatically ends (because we’re approaching the end of the screen). In those cases, the gesture stops, the offset exists, and we can’t reach the end. In other cases, we can continue moving without being stopped. In those cases, we can properly reach the end.
Custom Slider
What if we fix these problems with writing our own Slider? Let’s split it into three parts - drawing, gesture handling, and the overall composable. The full code for this is here.
Drawing
Let’s start with the drawing part. Drawing is fairly straightforward - we have a track and a thumb. The track itself has 2 “pieces” - the background (showing the full width of the seekbar), and the foreground (showing the current progress). The thumb overlays both of those to show where the current progress is.
This looks like:
Canvas(Modifier.fillMaxWidth().height(56.dp)) {
// draw the track - background is from visualStart to visualEnd
drawLine(/* .. */)
// draw the progress track - from visualStart to the thumb's x
drawLine(/* .. */)
// draw the thumb centered at its thumbX
drawRoundRect(/* .. */)
}
Gesture Handling
So what about the gesture handling?
fun pageFromX(x: Float): Int {
val start = 0f + thumbHalfPx
val end = widthPx.floatValue - thumbHalfPx
val range = (end - start).takeIf { it > 0f }
?: return latestCurrentPage
val fraction = ((x - start) / range).coerceIn(0f, 1f)
return (1f + fraction * (604f - 1f))
.roundToInt()
.coerceIn(1, 604)
}
Modifier
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
onSetInteracting(true)
try {
val dragStart = awaitTouchSlopOrCancellation(down.id) {
change, _ ->
change.consume()
onSetCurrentPage(pageFromX(change.position.x))
}
if (dragStart != null) {
drag(dragStart.id) { change ->
change.consume()
onSetCurrentPage(pageFromX(change.position.x))
}
}
} finally {
onSetInteracting(false)
}
}
}
Touch handling ends up being straightforward - we wait for our first down gesture and set ourselves to interacting (causing the buttons to hide). We then wait for the finger to move at least a “touch slop” amount (the intention being that if you tap without moving and let go, we don’t change the page). Once that happens, we update the page based on the actual x position of the finger. This is what lets the thumb match the actual finger, even after the resize.
The rest is just watching the drag state and setting the page based on the x position of the finger relative to the current size of the screen. Our pageFromX function takes care of mapping the x position to a page number (by figuring out the percentage of the current thumb and mapping that to the range of pages).
Putting it Together
The rest is just tying these parts up into a Box containing the Canvas with the Modifier for handling gestures.
This works! Why? The main reason is that our custom Slider is effectively stateless (in the sense that it calculates based on the current width and the current touch position instead of an internal drag offset), thereby keeping our drawing coordinates and our touch coordinates in sync. With the M3 Slider, changing the underlying width of the track, or visually offsetting the Thumb both cause the internal state to become out of sync with the visual state. This is the core problem that our custom Slider solves.
What do we Lose?
While this works, it’s a very simplistic version relative to the real Slider. We lose several crucial things:
Accessibility Support
Google makes this one a bit easy for us to add, using the semantics modifier and setting progressBarRangeInfo -
Modifier
.semantics {
progressBarRangeInfo = ProgressBarRangeInfo(
current = currentPage.toFloat(),
range = range.first.toFloat()..range.last.toFloat(),
steps = (range.last - range.first - 1).coerceAtLeast(0),
)
setProgress { target ->
onSetCurrentPage(
target.roundToInt()
.coerceIn(range.first, range.last)
)
true
}
}
Keyboard Support
Under the hood, Slider’s SliderImpl sets a slideOnKeyEvents modifier that is private. This one handles various buttons - the directional keys, home, end, page up, and page down. Our code would also need to do this.
RTL Support
In this case, the slider is purely LTR. We’d need to handle RTL properly as well to have a fully functional slider.
Other Points
In general, with Google pushing us to support more platforms, will our custom Slider work well on a desktop or tablet? Will it work fine with a mouse or trackpad? In addition to all of this, we’d have to maintain this code ourselves and update it. Moreover, we hardcoded our colors instead of using Material theming out of the box.
Making a basic custom slider is easy, but making one that fully matches what the Material 3 one does is a lot of work. Nevertheless, it was fun to replicate this iOS effect on Android!