Engineering

Your gradient is measured in pixels, and that's the bug

Compose gradients take pixel coordinates, so a hardcoded end point produces a different result on every screen size. Float.POSITIVE_INFINITY and DrawScope.size are the two ways to make a brush relative.

MobileByteSensei 8 min read
Day 58 — Your gradient is measured in pixels, and that's the bug
androidcomposekotlingraphics

Day 58 of 100. Gradients are the most common custom drawing in an app, and they carry a coordinate-system trap that only shows up on a device you didn't test.

The case this post is about

One header, a purple-to-blue gradient across the top of a screen, correct on the phone it was written on:

Box(
    Modifier
        .fillMaxWidth()
        .height(200.dp)
        .background(
            Brush.horizontalGradient(

On a tablet the gradient finishes about a third of the way across and the rest of the header is flat blue. Nothing threw, nothing warned, and the code is the code everybody writes.

The reason is that brush coordinates are absolute pixels in the drawing space, and the default end value got replaced by a measured constant at some point because that is what the View system trained everybody to do. This post follows this one header to the fix, which is shorter than the bug.

The symptom: a header gradient that's wrong on a tablet

A header gradient that's wrong on a tablet:

Box(
    Modifier
        .fillMaxWidth()
        .height(200.dp)
        .background(
            Brush.horizontalGradient(
                colors = listOf(Purple, Blue),
                startX = 0f,
                endX = 1080f,          // "the screen width"
            )
        )
)

On a 1080px-wide phone it's perfect. On a 1440px phone the gradient completes at three-quarters and the last quarter is flat blue. In a split-screen pane at 600px it's cut off part-way through the ramp.

The number was measured once, on one device, in pixels.

Why looking up the screen width doesn't fix it

The obvious fix is to look up the screen width:

val widthPx = with(LocalDensity.current) {
    LocalConfiguration.current.screenWidthDp.dp.toPx()
}
Brush.horizontalGradient(listOf(Purple, Blue), endX = widthPx)

Better, and still wrong for the actual requirement. The gradient should span the shape it fills, which may not be the screen — a card inset by 32dp, a pane in a two-pane layout, a chip. And it recomputes on every configuration change for a value the drawing system already knows.

Brush coordinates are absolute pixels; infinity means "to the end of whatever this fills"

The mechanism: brush coordinates are absolute pixels

Brush coordinates are absolute pixels in the drawing space, not fractions. The default end value is Float.POSITIVE_INFINITY, which is a sentinel meaning "the far edge of whatever this brush is applied to":

Brush.horizontalGradient(listOf(Purple, Blue))     // 0 → the shape's own width

That's the whole fix. Omit the coordinates and the gradient is relative to the element, correct at every size, with no configuration lookup.

When you do need explicit stops, get the size from the draw scope rather than the screen:

Modifier.drawBehind {
    drawRect(
        Brush.horizontalGradient(
            colors = listOf(Purple, Blue),
            startX = size.width * 0.2f,      // 20% in
            endX = size.width * 0.8f,        // 80% across
        )
    )
}

size inside a DrawScope is the element's own size in pixels — Day 57's point, and the reason draw-scope drawing composes better than a Modifier.background with baked constants.

Packaged as the modifier the header actually uses, the two things that go wrong — running out early and banding — are both handled by the element's own size rather than by a constant:

fun Modifier.gradientHeader(
    from: Color,
    to: Color,
    fadeFrom: Float = 0.6f,     // fraction of the element, NOT a dp or a pixel count
): Modifier = drawBehind {
    // Every coordinate derives from `size`, so this is correct at 320dp and at 1280dp.
    val brush = Brush.horizontalGradient(
        colorStops = arrayOf(
            0f to from,
            fadeFrom to from,
            1f to to,
        ),
        startX = 0f,
        endX = size.width,
        tileMode = TileMode.Clamp,   // Clamp, not Repeat: a header must not tile
    )
    drawRect(brush = brush)

    // Banding is a function of how many pixels each colour step gets. Below roughly
    // one device pixel per step the ramp is visibly striped, and a screenshot taken
    // on a high-density device will not show it.
    val steps = ((to.red - from.red).absoluteValue * 255).toInt().coerceAtLeast(1)
    if (size.width / steps < 1f) {
        drawRect(brush = brush, blendMode = BlendMode.Overlay, alpha = 0.02f)  // dither
    }
}

The same modifier at the three widths a preview should cover:

 320dp  gradient spans 0 -> 320dp    fade begins at 192dp
 600dp  gradient spans 0 -> 600dp    fade begins at 360dp
1280dp  gradient spans 0 -> 1280dp   fade begins at 768dp

Compare the version this replaced, which passed endX = 1080f because that was the width of the phone it was written on:

 320dp  gradient clipped at 320 of 1080  — only the first third of the ramp is visible
1280dp  gradient ends at 1080 of 1280    — flat `to` colour for the last 200dp

Neither of those is a rendering bug and neither produces a warning. They are a measured constant being asked to describe a size it was never measured from.

The brush types

Five, and they differ in how they map a position to a colour:

Brush.linearGradient(colors)                       // along an arbitrary line
Brush.horizontalGradient(colors)                   // linear, left→right
Brush.verticalGradient(colors)                     // linear, top→bottom
Brush.radialGradient(colors, center, radius)       // outward from a point
Brush.sweepGradient(colors)                        // around a point, by angle

Plus SolidColor(color), which is a Brush too — useful because it means anything taking a Brush also takes a flat colour without a separate code path.

sweepGradient is the one people don't know exists and reach for a library to get: it's what a circular progress ring or a colour wheel needs, and it's one call. Its one quirk: the sweep starts at 3 o'clock, so a ring that should begin at the top needs Modifier.rotate(-90f).

Colour stops

By default colours are distributed evenly. colorStops places them:

Brush.verticalGradient(
    colorStops = arrayOf(
        0.0f to Color.Transparent,
        0.6f to Color.Transparent,
        1.0f to Color.Black.copy(alpha = 0.7f),
    )
)

That's the scrim under a photo caption — transparent for the top 60%, then darkening. It appears in nearly every media app and is worth having as a shared brush rather than retyped per screen.

Uneven stops are also how you get a sharp transition: two stops at the same position produce a hard edge rather than a ramp, which is how a two-tone background or a progress bar fill is drawn without two shapes.

TileMode, for when the shape is bigger than the gradient

If you give explicit coordinates that don't span the shape, TileMode decides what happens in the remainder:

Brush.horizontalGradient(
    colors = listOf(Purple, Blue),
    endX = 100f,
    tileMode = TileMode.Repeated,      // stripes
)

Clamp (the default) extends the last colour — which is exactly the flat-blue tail from the top of this post. Repeated tiles, Mirror alternates direction. A repeating gradient is how you draw a striped or hatched fill in one call, with no loop.

Brushes work on text too

Not obvious, and genuinely useful:

Text(
    "Premium",
    style = TextStyle(
        brush = Brush.linearGradient(listOf(Gold, Amber)),
    ),
)

Gradient text with no bitmap and no mask. It respects the type scale, wraps normally and stays selectable, which a rasterised gradient title does not.

The same applies to Modifier.border(width, brush, shape) — gradient borders are a brush, not a special API.

Banding, and why it doesn't show in a screenshot

A long, subtle gradient — near-black to black over 800dp — shows visible steps on an 8-bit-per-channel display. The intermediate colours quantise, and the eye is very good at spotting where the boundaries land.

Two mitigations. Add slight noise so the quantisation is irregular rather than aligned in straight bands. Or avoid gradients with very little colour distance over a very long span, which is the underlying cause: a smooth ramp needs enough distinct values between its endpoints to fill the distance.

It's the one gradient problem that never reproduces in a design review, because image compression hides it. Check it on a device, with the screen brightness down, which is where users will meet it.

The cost

A gradient is a shader, and building one is not free. Creating a Brush inside a composable that recomposes frequently allocates per composition:

// Allocates every recomposition
Box(Modifier.background(Brush.verticalGradient(listOf(a, b))))

// Allocated once
val brush = remember(a, b) { Brush.verticalGradient(listOf(a, b)) }
Box(Modifier.background(brush))

For a static background this rarely matters. Inside a LazyColumn item or an animating component it does, and remember with the colours as keys is the whole fix.

How to prove it: one preview at three widths

The relative-coordinate claim is one preview at three widths:

@Preview(widthDp = 320) @Preview(widthDp = 600) @Preview(widthDp = 1000)
@Composable fun GradientHeader() = AppTheme { Header() }

A relative gradient completes at every width. A pixel-coordinate one is visibly different in each render — the failure is obvious once you look at three sizes together, and invisible when you look at one.

For the allocation, the recomposition counts plus a log in the brush construction. If it prints per frame during an animation, it needs a remember.

What this generalizes to: relative beats absolute

The lesson is relative beats absolute, and sentinels beat lookups. The gradient doesn't need to know the screen width; it needs to know its own extent, and the drawing system already does. Float.POSITIVE_INFINITY as "to the edge" is a small API decision that removes an entire class of bug.

It's the same shape as Day 35's innerPadding and Day 29's window size classes: at every level, the fix was to stop substituting a measured constant for a value the system can provide. Three pillars, one habit.

The tell is always the same: a number in the source that came from measuring one device. 1080f, 64.dp for an app bar, 600 for a tablet breakpoint — each one is a snapshot of a configuration that will not hold, and each has a system-provided alternative that does.

Tomorrow, Day 59: shapes and shadows — drawing paths, and why elevation looks different in Material 3.


Day 58 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Brush.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS