Brush: gradients and shaders

A Brush in Compose describes how something is drawn on screen: it determines the color(s) that are drawn in the drawing area (i.e. a circle, square, path). There are a few built-in Brushes that are useful for drawing, such as LinearGradient, RadialGradient or a plain SolidColor brush.

Brushes can be used with Modifier.background(), TextStyle, or DrawScope draw calls to apply the painting style to the content being drawn.

For example, a horizontal gradient brush can be applied to drawing a circle in DrawScope:

val brush = Brush.horizontalGradient(listOf(Color.Red, Color.Blue))
Canvas(
    modifier = Modifier.size(200.dp),
    onDraw = {
        drawCircle(brush)
    }
)
Circle drawn with Horizontal Gradient
Figure 1: Circle drawn with Horizontal Gradient

Gradient brushes

There are many built-in gradient brushes that can be used to achieve different gradient effects. These brushes allow you to specify the list of colors that you would like to create a gradient from.

A list of available gradient brushes and their corresponding output:

Gradient Brush Type Output
Brush.horizontalGradient(colorList) Horizontal Gradient
Brush.linearGradient(colorList) Linear Gradient
Brush.verticalGradient(colorList) Vertical Gradient
Brush.sweepGradient(colorList)
Note: To get a smooth transition between colors - set the last color to the start color.
Sweep Gradient
Brush.radialGradient(colorList) Radial Gradient

Change distribution of colors with colorStops

To customize how the colors appear in the gradient, you can tweak the colorStops value for each one. colorStops should be specified as a fraction, between 0 and 1. Values greater than 1 will result in those colors not rendering as part of the gradient.

You can configure the color stops to have different amounts, such as less or more of one color:

val colorStops = arrayOf(
    0.0f to Color.Yellow,
    0.2f to Color.Red,
    1f to Color.Blue
)
Box(
    modifier = Modifier
        .requiredSize(200.dp)
        .background(Brush.horizontalGradient(colorStops = colorStops))
)

The colors are dispersed at the provided offset as defined in the colorStop pair, less yellow than red and blue.

Brush configured with different color stops
Figure 2: Brush configured with different color stops

Repeat a pattern with TileMode

Each gradient brush has the option to set a TileMode on it. You may not notice the TileMode if you haven’t set a start and end for the gradient, as it’ll default to fill the whole area. A TileMode will only tile the gradient if the size of the area is bigger than the Brush size.

The following code will repeat the gradient pattern 4 times, since the endX is set to 50.dp and the size is set to 200.dp:

val listColors = listOf(Color.Yellow, Color.Red, Color.Blue)
val tileSize = with(LocalDensity.current) {
    50.dp.toPx()
}
Box(
    modifier = Modifier
        .requiredSize(200.dp)
        .background(
            Brush.horizontalGradient(
                listColors,
                endX = tileSize,
                tileMode = TileMode.Repeated
            )
        )
)

Here is a table detailing what the different Tile Modes do for the HorizontalGradient example above:

TileMode Output
TileMode.Repeated: Edge is repeated from last color to first. TileMode Repeated
TileMode.Mirror: Edge is mirrored from last color to first. TileMode Mirror
TileMode.Clamp: Edge is clamped to the final color. It’ll then paint the closest color for the rest of the region. Tile Mode Clamp
TileMode.Decal: Render only up to the size of the bounds. TileMode.Decal leverages transparent black to sample content outside the original bounds whereas TileMode.Clamp samples the edge color. Tile Mode Decal

TileMode works in a similar way for the other directional gradients, the difference being the direction that the repetition occurs.

Change brush Size

If you know the size of the area in which your brush will be drawn, you can set the tile endX as we’ve seen above in the TileMode section. If you are in a DrawScope, you can use its size property to get the size of the area.

If you don't know the size of your drawing area (for example if the Brush is assigned to Text), you can extend Shader and utilize the size of the drawing area in the createShader function.

In this example, divide the size by 4 to repeat the pattern 4 times:

val listColors = listOf(Color.Yellow, Color.Red, Color.Blue)
val customBrush = remember {
    object : ShaderBrush() {
        override fun createShader(size: Size): Shader {
            return LinearGradientShader(
                colors = listColors,
                from = Offset.Zero,
                to = Offset(size.width / 4f, 0f),
                tileMode = TileMode.Mirror
            )
        }
    }
}
Box(
    modifier = Modifier
        .requiredSize(200.dp)
        .background(customBrush)
)

Shader size divided by 4
Figure 3: Shader size divided by 4

You can also change the brush size of any other gradient, such as radial gradients. If you don't specify a size and center, the gradient will occupy the full bounds of the DrawScope, and the center of the radial gradient defaults to the center of the DrawScope bounds. This results in the radial gradient's center appearing as the center of the smaller dimension (either width or height):

Box(
    modifier = Modifier
        .fillMaxSize()
        .background(
            Brush.radialGradient(
                listOf(Color(0xFF2be4dc), Color(0xFF243484))
            )
        )
)

Radial Gradient set without size changes
Figure 4: Radial Gradient set without size changes

When the radial gradient is changed to set the radius size to the max dimension, you can see that it produces a better radial gradient effect:

val largeRadialGradient = object : ShaderBrush() {
    override fun createShader(size: Size): Shader {
        val biggerDimension = maxOf(size.height, size.width)
        return RadialGradientShader(
            colors = listOf(Color(0xFF2be4dc), Color(0xFF243484)),
            center = size.center,
            radius = biggerDimension / 2f,
            colorStops = listOf(0f, 0.95f)
        )
    }
}

Box(
    modifier = Modifier
        .fillMaxSize()
        .background(largeRadialGradient)
)

Bigger radius on radial gradient, based on size of area
Figure 5: Bigger radius on radial gradient, based on size of area

It is worth noting that the actual size that is passed into the creation of the shader is determined from where it is invoked. By default, Brush will reallocate its Shader internally if the size is different from the last creation of the Brush, or if a state object used in creation of the shader has changed.

The following code creates the shader three different times with different sizes, as the size of the drawing area changes:

val colorStops = arrayOf(
    0.0f to Color.Yellow,
    0.2f to Color.Red,
    1f to Color.Blue
)
val brush = Brush.horizontalGradient(colorStops = colorStops)
Box(
    modifier = Modifier
        .requiredSize(200.dp)
        .drawBehind {
            drawRect(brush = brush) // will allocate a shader to occupy the 200 x 200 dp drawing area
            inset(10f) {
      /* Will allocate a shader to occupy the 180 x 180 dp drawing area as the
       inset scope reduces the drawing  area by 10 pixels on the left, top, right,
      bottom sides */
                drawRect(brush = brush)
                inset(5f) {
        /* will allocate a shader to occupy the 170 x 170 dp drawing area as the
         inset scope reduces the  drawing area by 5 pixels on the left, top,
         right, bottom sides */
                    drawRect(brush = brush)
                }
            }
        }
)

Use an image as a brush

To use an ImageBitmap as a Brush, load up the image as an ImageBitmap, and create an ImageShader brush:

val imageBrush =
    ShaderBrush(ImageShader(ImageBitmap.imageResource(id = R.drawable.dog)))

// Use ImageShader Brush with background
Box(
    modifier = Modifier
        .requiredSize(200.dp)
        .background(imageBrush)
)

// Use ImageShader Brush with TextStyle
Text(
    text = "Hello Android!",
    style = TextStyle(
        brush = imageBrush,
        fontWeight = FontWeight.ExtraBold,
        fontSize = 36.sp
    )
)

// Use ImageShader Brush with DrawScope#drawCircle()
Canvas(onDraw = {
    drawCircle(imageBrush)
}, modifier = Modifier.size(200.dp))

The Brush is applied to a few different types of drawing: a background, the Text and Canvas. This outputs the following:

ImageShader Brush used in different ways
Figure 6: Using ImageShader Brush to draw a background, draw Text and draw a Circle

Notice that the text is now also rendered using the ImageBitmap to paint the pixels for the text.

Advanced example: Custom brush

AGSL RuntimeShader brush

AGSL offers a subset of GLSL Shader capabilities. Shaders can be written in AGSL and used with a Brush in Compose.

To create a Shader brush, first define the Shader as AGSL shader string:

@Language("AGSL")
val CUSTOM_SHADER = """
    uniform float2 resolution;
    layout(color) uniform half4 color;
    layout(color) uniform half4 color2;

    half4 main(in float2 fragCoord) {
        float2 uv = fragCoord/resolution.xy;

        float mixValue = distance(uv, vec2(0, 1));
        return mix(color, color2, mixValue);
    }
""".trimIndent()

The shader above takes two input colors, calculates the distance from the bottom left (vec2(0, 1)) of the drawing area and does a mix between the two colors based on the distance. This produces a gradient effect.

Then, create the Shader Brush, and set the uniforms for resolution - the size of the drawing area, and the color and color2 you want to use as input to your custom gradient:

val Coral = Color(0xFFF3A397)
val LightYellow = Color(0xFFF8EE94)

@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
@Preview
fun ShaderBrushExample() {
    Box(
        modifier = Modifier
            .drawWithCache {
                val shader = RuntimeShader(CUSTOM_SHADER)
                val shaderBrush = ShaderBrush(shader)
                shader.setFloatUniform("resolution", size.width, size.height)
                onDrawBehind {
                    shader.setColorUniform(
                        "color",
                        android.graphics.Color.valueOf(
                            LightYellow.red, LightYellow.green,
                            LightYellow
                                .blue,
                            LightYellow.alpha
                        )
                    )
                    shader.setColorUniform(
                        "color2",
                        android.graphics.Color.valueOf(
                            Coral.red,
                            Coral.green,
                            Coral.blue,
                            Coral.alpha
                        )
                    )
                    drawRect(shaderBrush)
                }
            }
            .fillMaxWidth()
            .height(200.dp)
    )
}

Running this, you can see the following rendered on screen:

Custom AGSL Shader running in Compose
Figure 7: Custom AGSL Shader running in Compose

It's worth noting that you can do a lot more with shaders than just gradients, as it's all math-based calculations. For more information on AGSL, check out the AGSL documentation.

Additional resources

For more examples of using Brush in Compose, check out the following resources: