← Blog
jetpack-composecompose-multiplatformandroidkotlindebugging

Why I Threw Away Material Slider — and the Stale Closure Bug That Made Me

April 3, 2026

Building a custom gradient slider in Jetpack Compose Multiplatform, and the one-liner that fixed a subtle, maddening bug.

In PickPerfect — a color-guessing game built with Compose Multiplatform — players pick a color using three sliders: Hue, Saturation, and Brightness (HSV model). To make the UI intuitive, each slider needs a live gradient as its track:

  • SAT runs from grey → the current hue's pure color.
  • LUM runs from black → the color at the current hue + saturation.

The Problem with Material Slider

Material's default Slider component is great for standard use cases, but it doesn't support custom track rendering. You get a flat, primary-colored fill. For a color picker, that's just not enough.

The hue gradient slider — the track itself is the gradient. You can't do this with Material Slider.

Because the built-in components couldn't handle this, I decided to build it from scratch.

Anatomy of the Custom Slider

The structure of a custom slider is surprisingly simple:

  • BoxWithConstraints — to know the track width in pixels
  • Box — the gradient track: a clipped rounded rect that handles all touch events
  • Box — the thumb: a white-bordered circle, offset by a fraction

The thumb position is pure math:

val fraction = ((value - min) / (max - min)).coerceIn(0f, 1f)
val thumbOffset = fraction * (trackWidth - thumbSizePx)

Touch handling requires two separate pointerInput blocks — one for taps, and one for drags. If you chain both detectors inside a single block, they compete and one silently wins. Two separate blocks give each its own coroutine, allowing them to work in harmony:

.pointerInput(valueRange) {
    detectTapGestures { offset ->
        hapticTick()
        val newFraction = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
        onValueChange(min + newFraction * (max - min))
    }
}
.pointerInput(valueRange) {
    detectHorizontalDragGestures(
        onDragStart = { dragStarted = false },
        onHorizontalDrag = { change, _ ->
            if (!dragStarted) {
                hapticTick()
                dragStarted = true
            }
            val newFraction = (change.position.x / size.width.toFloat()).coerceIn(0f, 1f)
            onValueChange(min + newFraction * (max - min))
        }
    )
}
All three sliders at hue=210°, sat=75%, lum=85%. Gradients and thumb colors all in sync.

The Bug

Everything worked beautifully — until I moved the Hue slider.

Dragging Hue updated the SAT and LUM gradient colors instantly. But the thumb colors on those sliders would lag or snap. Worse: sometimes dragging Hue would silently reset Saturation or Brightness back to their initial values. This wasn't just a visual glitch — it was corrupting the actual state.

Here's why:

pointerInput launches a coroutine when the composable enters the composition. That coroutine captures its onValueChange lambda at launch time. When it fires, it calls the stale version of the lambda — the one that closed over the old state values.

So, if you drag the Hue slider, its coroutine fires onPickedChange(newHue, state.pickedS, state.pickedV). But state.pickedS inside that closure is the value from when the coroutine was initially started. Saturation resets silently.

The gradient updated to hue=210° (blue tones) — but the SAT and LUM thumbs are still red. They were computed from the stale hue=0°.

The Fix: rememberUpdatedState

The fix is exactly one line of code:

val latestOnValueChange by rememberUpdatedState(onValueChange)

rememberUpdatedState returns a State<T> that always holds the latest value of the lambda. The coroutine doesn't restart on recomposition (so there are no wasted allocations), but when it reads latestOnValueChange(...), it dereferences the State at call time — not at capture time. It is always fresh.

Here is the full pattern in context:

@Composable
fun GradientSlider(
    // ...
    onValueChange: (Float) -> Unit
) {
    // Without this, coroutines capture stale lambdas on every recomposition
    val latestOnValueChange by rememberUpdatedState(onValueChange)
 
    Box(
        modifier = Modifier.pointerInput(valueRange) {
            detectHorizontalDragGestures { change, _ ->
                val newFraction = (change.position.x / size.width.toFloat()).coerceIn(0f, 1f)
                latestOnValueChange(min + newFraction * (max - min)) // Always latest!
            }
        }
    )
}

The general rule: any long-lived coroutine inside pointerInput, LaunchedEffect, or rememberCoroutineScope that calls a lambda passed from outside should have that lambda wrapped in rememberUpdatedState.

The Result

The complete picker: hex input field, three gradient sliders, color swatch. All shared KMP code — same on Android and iOS.

The whole component boils down to about 30 lines of composable logic with zero Material component dependencies. Gradients update live, the HEX field syncs bidirectionally, and thumbs always reflect the current state.

The rememberUpdatedState pattern is documented in the Compose official guides, but it's incredibly easy to miss — especially because the bug only appears under specific interaction sequences. You can easily ship to production without ever noticing it, until a user reports that dragging hue scrambles their entire color pick.

Hopefully this saves you a few hours of debugging.


PickPerfect is built with Compose Multiplatform and available on the App Store now.

← All postshello@zarzara.app