This is not going to be a regular android dev blog post. I’m going to try a new format that I want to call journaling to a solution. To give a sense of how different this is (to me), I’m using Google Docs for the drafting phase for the first time. Eventually this will be on my personal blog so the content needs to be exported as html or markdown.

Question

https://kotlinlang.slack.com/archives/CJLTWPH7S/p1753195511485519

If you don’t have access to the link, here is the question;

This question is from kotlinlang slack’s #compose channel.

The confusion in the question stems from the fact that when they apply the same brush to different parts of the text, the gradient is applied differently. But actually, the gradient is applied exactly the same and that’s the real problem. Here is my answer from the original thread.

This is an Android platform limitation. Android paint applies a given shader only to the entire text, not the specific span region. So when you want to draw a gradient over part of text, you can imagine that the gradient is sized according to full text, drawn behind, then only shown where you define the span.

The only way you can workaround this is if you define a custom ShaderBrush and use the metrics provided to you from TextLayoutResult. That way you can size and create your shader as you wish.

Let me give you the steps of how Brush is applied as a span in Compose and consequently in Android Platform.

  1. AnnotatedString has a SpanStyle Range somewhere in it.
  2. When Compose wants to render this AnnotatedString, it creates a SpannableString to take advantage of the available Android Platform Text APIs.
    1. Please look at this function and its surrounding ones https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidParagraphHelper.android.kt;l=49
  3. Brush needs to be converted into a Span that Android understands.
    1. Here we use ShaderBrushSpan that implements UpdateAppearance span https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/style/ShaderBrushSpan.android.kt
  4. During the draw phase, all UpdateAppearance spans get a call to their updateDrawState function.
    1. ShaderBrushSpan simply sets the TextPaint#shader to its internal derivedStateOf shader.
    2. Having a derivedState run the backing shader was a deliberate decision r.android.com/2791253. It enables efficient animations using a ShaderBrush
  5. To create a shader, ShaderBrushSpan needs a definitive Size.
    1. Uh-oh, UpdateAppearance doesn’t give us that.
    2. Actually there is no way to certainly know what parts of the text this span spans.
    3. It can even be multiple lines, in that case size becomes an ambiguous term.
    4. Compose simply uses the entire size of the text layout and this is actually the correct approach, we will come to that in a second.
    5. https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/AndroidParagraph.android.kt;l=256-261
  6. Compose creates a StaticLayout using the new SpannableString.
  7. Then finally Android’s rendering stack draws this StaticLayout and while drawing it, the shaders in spans are drawn onto the canvas from the origin (0.0) position. But it also guaranteed that only the shader span parts draw the text with the shader applied.

Ok that last step is loaded so here is an illustration of what is going on.

This is our main text, and we would like “at aliquam” on the third line to have a gradient of Color.Blue, Color.Red, Color.Green. That would look horrible but I’m just being random here.

To help us better visualize what is going to happen, we will use rectangles instead of words and imagine that we are drawing rectangles with gradients and colors.

Highlighting the spanned region, we have;

Now lets get more technical in terms of how Android really renders this paragraph. A piece of text that is drawn with the exact same style. in the same direction, on the same line is called a Run. I’m going to make this simple and just say that the only span we have in this paragraph is the Brush on “at aliquam”. Then the runs would be;

The runs other than “at aliquam” are uninteresting. They have a constant color, and the paint that draws them just uses this color. Also notice that the third line is now divided into 3 separate runs because it is split by a span. What we are trying to understand is what is going to happen with the shader.

Before we get into that, I will inline the ShaderBrushSpan code here for reference;

/** A span that applies [ShaderBrush] to TextPaint after receiving a specified size */
internal class ShaderBrushSpan(val shaderBrush: ShaderBrush, val alpha: Float) :
    CharacterStyle(), UpdateAppearance {

    // Set by AndroidParagraph to the size of the entire paragraph.
    var size: Size by mutableStateOf(Size.Unspecified)

    private val shaderState: State<Shader?> = derivedStateOf {
        if (size.isUnspecified || size.isEmpty()) {
            null
        } else {
            shaderBrush.createShader(size)
        }
    }

    override fun updateDrawState(textPaint: TextPaint) {
        textPaint.setAlpha(alpha)
        textPaint.shader = shaderState.value
    }
}

ShaderBrushSpan is going to read the shader from shaderState ‘s value and set it to TextPaint. I’m open to digress, so we should discover what a ShaderBrush is and how it relates to the familiar Brush APIs like Brush.horizontalGradient or Brush.radialGradient

I’m going to skip all the delegated calls and let you know that besides the radial gradient, all linear ones eventually create the following object. For brevity I omitted functions like equals, hashCode. etc.

/** Brush implementation used to apply a linear gradient on a given [Paint] */
@Immutable
class LinearGradient
internal constructor(
    @Suppress("PrimitiveInCollection") internal val colors: List<Color>,
    @Suppress("PrimitiveInCollection") internal val stops: List<Float>? = null,
    internal val start: Offset,
    internal val end: Offset,
    internal val tileMode: TileMode = TileMode.Clamp,
) : ShaderBrush(), Interpolatable {

    override val intrinsicSize: Size
        get() =
            Size(
                if (start.x.isFinite() && end.x.isFinite()) abs(start.x - end.x) else Float.NaN,
                if (start.y.isFinite() && end.y.isFinite()) abs(start.y - end.y) else Float.NaN,
            )

    override fun createShader(size: Size): Shader {
        val startX = if (start.x == Float.POSITIVE_INFINITY) size.width else start.x
        val startY = if (start.y == Float.POSITIVE_INFINITY) size.height else start.y
        val endX = if (end.x == Float.POSITIVE_INFINITY) size.width else end.x
        val endY = if (end.y == Float.POSITIVE_INFINITY) size.height else end.y
        return LinearGradientShader(
            colors = colors,
            colorStops = stops,
            from = Offset(startX, startY),
            to = Offset(endX, endY),
            tileMode = tileMode,
        )
    }
}

When you create a Brush with a call like;

Brush.linearGradient(listOf(Color.Red, Color.Blue, Color.Green))

You are not really giving any coordinates or sizing information. That is the beauty of the Brush API. It infers boundaries at the time of the shader creation. Therefore, the abstract class of ShaderBrush accepts size: Size in its createShader function. If you read the code carefully in the LinearGradient class, you will see that the default POSITIVE_INFINITY value is evaluated to size boundaries.

All of this is to say that whatever size is passed into a ShaderBrush, your shader is going to have that size if you are using the Brush factory functions with the default optional parameters.

Now we can continue with our span;

Recall that when updateDrawState is called, the size is already passed to the span class and it is equal to the entire paragraph’s size. So the shader will be created using this size, a shader starting from topLeft and ending at bottomRight.

I know I haven’t specified it yet so I’m just going to accept here that our Brush was created by

Brush.horizontalGradient(listOf(Color.Blue, Color.Red, Color.Green))

In that case the shader is going to be;

I want to intercept here to clear a possible confusion. Even if we carefully calculated the bounding region of the span range and created the shader according to that size, StaticLayout draw logic would still have placed the shader at the 0,0 coordinate, making it look like this;

*I’m inferring the tileMode is TileMode.Clamp

Not great in either case. At least when we create the shader specifically for the entire text layout, the gradient becomes a bit more predictable since it covers the whole layout. Imagine what would have happened if the spanned region was somewhere here;

It would be very confusing to see only the color green.

Anyway, when we look at the real (non hypothetical) final illustration one more time;

We clearly see that the gradient applied on the spanned region definitely does not show any green color even though our Brush clearly wanted to apply all three colors to the specified range.

Now we know the problem and the mechanism behind it, we can get a bit creative with it,

Solution

For this part of the post I’m going to assume that we are going to take a lot of happy paths.

The text is fully in LTR script like English and the annotated range is on a single line. I’m planning on doing this for multiple lines as well in this exploration but RTL and BiDi might delay this post for a couple of weeks.

Q; How can we squeeze in the entire gradient we want in the specific range?

Here we are simply asking what kind of a shader can we create that takes the size of the entire paragraph but still applies the desired gradient at the given rectangle.

What we need to do is fill in the blanks. You can be really creative while filling the remaining region but I’m going to go with the most straight-forward option; gradient is just defined horizontally in the given rectangle region, the rest is clamped to the boundary color.

We are almost there. Now we need to do some quick maths.

First of all, the layout direction is accepted as LTR so start refers to left, end refers to right in most cases.

The gradient needs to start from the span’s most left coordinate, and end at its most right coordinate. How would we find these coordinates? And how would we create a Brush like that?

Do not forget that we are focusing on the single line case, therefore we have lots of options when it comes to finding the coordinates. Of course they are all going to depend on having a TextLayoutResult. We can get it by

var textLayout: TextLayoutResult? by remember { mutableStateOf(null) }
Text(
  
  onTextLayout = { textLayout = it }
)

Information: onTextLayout triggers during the layout phase. When you save its reference and use it during the draw phase, you wouldn’t be losing a frame. Everything would be rendered fine in the first frame. However if we were to use the TextLayoutResult in composition, then of course the first frame would have been wasted.

What is available to use from TextLayoutResult that could give us these coordinates?

  • getHorizontalPosition
    • Left = getHorizontalPosition for left most character.
    • Right = getHorizontalPosition for the character next to the right most character.
  • getBoundingBox
    • Left = getBoundingBox for left most character, use Rect.left
    • Right = getBoundingBox for right most character, use Rect.right
  • getPathForRange
    • getPathForRange between left and right most character
    • Left = path.getBounds().left
    • Right = path.getBounds().right

I would like to use getPathForRange since it would be a single call and it perfectly fits our use case.

The next question, how are we going to create a brush like this, has a very simple answer.

Brush.horizontalGradient has two optional arguments that we didn’t check yet;

* @param startX Starting x position of the horizontal gradient. Defaults to 0 which represents the left of the drawing area
* @param endX Ending x position of the horizontal gradient. Defaults to [Float.POSITIVE_INFINITY] which indicates the right of the specified drawing area

Putting 2 and 2 together, we get;

val box = textLayout!!.getPathForRange(60, 70).getBounds()
val brush = Brush.horizontalGradient(
  listOf(Color.Blue, Color.Red, Color.Green),
  box.left,
  box.right
)

Buuuut, we cannot exactly do this since AnnotatedString is created during composition and we have to create the shader after layout is complete. Brush is forcing our hands to give this information from composition. Luckily, we can just create our own lazy ShaderBrush.

fun lazyHorizontalGradient(
    colors: List<Color>,
    startX: () -> Float,
    endX: () -> Float
): Brush = object: ShaderBrush() {
    override fun createShader(size: Size): Shader {
        return LinearGradientShader(
            colors = colors,
            colorStops = null,
            from = Offset(startX(), 0f),
            to = Offset(endX(), 0f),
        )
    }
}

Warning; This is not a production ready implementation. Shader should be cached in the object against the size parameter. Otherwise we would be recreating the shader at every draw iteration.

Now this allows us to create a Brush for SpanStyle like

var box: Rect? by remember { mutableStateOf(null) }
// …
text = buildAnnotatedString {
    append("Lorem ipsum dolor sit amet, consectetur adipiscing elit. In ")
    withStyle(
        SpanStyle(
            brush = lazyHorizontalGradient(
                listOf(
                    Color.Blue,
                    Color.Red,
                    Color.Green
                ),
                startX = { box?.left ?: 0f },
                endX = { box?.right ?: Float.POSITIVE_INFINITY })
        )
    ) {
        append("at aliquam")
    }
    append(" lorem, eget ultricies enim.")
}

Well that was easy

By the way, here is a comparison between the direct Brush application and our solution, referenced against our illustrations;

Our solution
Naive approach

Crank it up a notch

Once we understood what was going on behind the scenes and how a Shader is created and applied by the underlying system, we could easily find a solution. Now we will go one step further, what if we want to apply a continuous horizontal gradient on a multi-line span?

This time, the span is “sit amet, consectetur adipiscing elit. In at aliquam”.

And the desired gradient along this span is

Creating this last diagram on draw.io actually forces one to understand where we should start from while preparing this gradient. But before mentioning that, can we reuse the same strategy from before, the one with filling the blanks?

We can imagine that there are 3 different spans that we need to calculate a gradient for. However, this approach has a glaring issue, we cannot know how the lines are going to break while creating the AnnotatedString itself. Unfortunately the span ranges cannot be lazily set during the draw phase.

The most possible angle to this problem comes from the constraint itself. What if we think that this is a single gradient?

Incredible diagram I drew here.

The hardest part is of course answering the question; how would we place 3 slices of a single shader on different vertical positions in another shader?

I was stumped to come up with an answer until I asked Gemini. (No, this is not a paid post, I’m a proud vibe coder)

Q; Can I concatenate multiple shaders vertically on android?

A; No, you cannot directly concatenate multiple Shader objects in Android with a simple “add” or “combine” method. A shader itself doesn’t have a defined size; it’s a rule for drawing that extends infinitely. […] The most effective and common way to achieve vertical concatenation is to draw each shader onto a Bitmap in the correct position, and then create a single BitmapShader from that composite bitmap.

So the simplest idea is that we would draw the shader parts on to a bitmap that is the size of the text layout, then use that bitmap as a shader. That sounds not so great performance wise but you know what, we are committed. I’m mostly worried about when someone decides to animate this. Otherwise for a static text I’d be fine with this performance trade-off.

How are we going to prepare this bitmap? More illustrations, yey…

The idea is;

  • Initialize a Bitmap and a Canvas to draw on it with the size of the text.
  • Create a regular horizontal shader that has the width of the entire span.
  • Place the shader on each line of the span and draw.
    • startX and endX will be different every time.
    • endX will always be startX + shaderWidth
  • On the first line, it starts from where the span starts from.
  • On the second line, some of the gradient is already used on the first line.
    • So we have to translate the shader a bit to the left so we can continue from where we left off on the first line
    • The amount to be pushed is the amount we already consumed from the shader, which is the span’s width in the first line. We will get to these calculations in a second
    • So startX for the second line is lineStart - consumedWidth
  • The same logic continues until the last line.
  • shaderWidth is equal to span’s entire width on all lines.

Eventually the bitmap we draw should be equal to;

Of course the final shader we will create will have the same width and height as the text layout. So it will need to be translated from top, but in our case since the span we chose starts from the first line, translateY would be 0.

How are we going to find the coordinates this time around? getPathForRange is not going to work anymore.

My approach is like the following;

  • Check the first character’s and last character’s line.
  • If they are the same, just use the solution from the first step.
  • If they are not the same, find how many total lines are there in this span.
    • For the first line, span is from left most position to line’s right.
    • For the last line, span is from line’s left to right most position.
    • Intermediate lines are fully in the span.

Ok, let’s code it out

data class SpanLine(
    val left: Float,
    val right: Float,
    val height: Float
)

fun TextLayoutResult.lineHeight(line: Int): Float = getLineBottom(line) - getLineTop(line)

// …

var lineCoords: List<SpanLine>? by remember { mutableStateOf(null) }
var translateY: Float by remember { mutableFloatStateOf(0f) }
// …
onTextLayout = { textLayout ->
    val firstLine = textLayout.getLineForOffset(18)
    val lastLine = textLayout.getLineForOffset(70)
    translateY = textLayout.getLineTop(firstLine)
    lineCoords = (firstLine..lastLine).map { line ->
        when (line) {
            firstLine -> {
                SpanLine(
                    textLayout.getBoundingBox(18).left,
                    textLayout.getLineRight(line),
                    textLayout.lineHeight(line)
                )
            }

            lastLine -> {
                SpanLine(
                    textLayout.getLineLeft(line),
                    textLayout.getBoundingBox(70).right,
                    textLayout.lineHeight(line)
                )
            }

            else -> {
                SpanLine(
                    textLayout.getLineLeft(line),
                    textLayout.getLineRight(line),
                    textLayout.lineHeight(line)
                )
            }
        }
    }
},

We are simply calculating the orange rectangles that we draw on the span and lines.

For each orange rectangle we get left, right coordinates, and the height of the rectangle. Since these rectangles are stacked on top of each other, we only keep a translateY value to get the y positioning of the top rectangle from the first line. Afterwards each line’s height can inform the next line’s top.

Concatenating and creating a BitmapShader is easier than it sounds once we have the parameters ready

private fun concatenateShadersVertically(
    shaders: List<Pair<Shader, Float>>,
    translateY: Float,
    shaderWidth: Int,
    shaderHeight: Int
): Shader {
    val compositeBitmap = createBitmap(shaderWidth, shaderHeight)
    val compositeCanvas = Canvas(compositeBitmap)

    val paint = Paint(Paint.ANTI_ALIAS_FLAG)

    var top = translateY
    shaders.forEachIndexed { index, (shader, lineHeight) ->
        paint.shader = shader
        val bottom = top + lineHeight
        val rect = RectF(0f, top, shaderWidth.toFloat(), bottom)
        compositeCanvas.drawRect(rect, paint)
        top += lineHeight
    }

    return ImageShader(compositeBitmap.asImageBitmap())
}

shaders list is a combination of a horizontal shader that has its X coordinates realized, and the height that it is supposed to have. We create a bitmap, then start drawing on it with our shaders.

The final part is the actual ShaderBrush that we are going to pass to SpanStyle. And here it is

fun lazyHorizontalGradient(
    colors: List<Color>,
    translateY: () -> Float,
    lineCoords: () -> List<SpanLine>,
): Brush = object : ShaderBrush() {

    var createdShader: Shader? = null
    var _size: Size = Size.Zero
    var _translateY: Float = Float.NaN
    var _lineCoords: List<SpanLine> = emptyList()

    override fun createShader(size: Size): Shader {
        val lines = lineCoords()
        val translateY = translateY()

        if (createdShader != null && _size == size && _translateY == translateY && _lineCoords == lines) {
            return createdShader!!
        }

        val lineCount = lines.size
        if (lineCount == 0) {
            // not important, lineCoords are being evaluated.
            return LinearGradientShader(
                colors = colors,
                colorStops = null,
                from = Offset(0f, 0f),
                to = Offset(size.width, size.height),
            )
        }

        val totalWidth = lines.map { it.right - it.left }.sum()
        var startXDelta = 0f
        return concatenateShadersVertically(
            shaders = lines.map { (start, end, lineHeight) ->
                val startX = start - startXDelta
                val endX = startX + totalWidth
                startXDelta += end - start

                LinearGradientShader(
                    colors = colors,
                    colorStops = null,
                    from = Offset(startX, 0f),
                    to = Offset(endX, 0f),
                ) to lineHeight
            },
            translateY = translateY,
            shaderWidth = size.width.toInt(),
            shaderHeight = size.height.toInt()
        )
    }
}

This time I also added the caching bits. One interesting piece of information I would like to share is that when you call various methods in TextLayoutResult, it causes a measure pass on the text layout but sometimes this measure pass fully draws the text on a non-existing surface. It has no performance implications but it ends up calling updateDrawState on the attached spans. This also means our createShader function also gets called. Therefore we should be careful about assuming that createShader will only ever be called during the draw phase. I might have said something like that in this post, please ignore it…

Oh, before I forgot

ta-daaa

No conclusion for a quick summary of lessons learned in this post, we are done with the implementation. See you in the next one.


The full implementation

@Composable
fun BrushSpanDemo(modifier: Modifier = Modifier) {
    var lineCoords: List<SpanLine>? by remember { mutableStateOf(null) }
    var translateY: Float by remember { mutableFloatStateOf(0f) }
    Text(
        text = buildAnnotatedString {
            append("Lorem ipsum dolor ")
            withStyle(
                SpanStyle(
                    brush = lazyHorizontalGradient(
                        listOf(
                            Color.Blue,
                            Color.Red,
                            Color.Green
                        ),
                        lineCoords = { lineCoords ?: emptyList() },
                        translateY = { translateY }
                    )
                )
            ) {
                append("sit amet, consectetur adipiscing elit. In at aliquam")
            }
            append(" lorem, eget ultricies enim.")
        },
        fontSize = 32.sp,
        lineHeight = 48.sp,
        onTextLayout = { textLayout ->
            val firstLine = textLayout.getLineForOffset(18)
            val lastLine = textLayout.getLineForOffset(70)
            translateY = textLayout.getLineTop(firstLine)
            lineCoords = (firstLine..lastLine).map { line ->
                when (line) {
                    firstLine -> {
                        SpanLine(
                            textLayout.getBoundingBox(18).left,
                            textLayout.getLineRight(line),
                            textLayout.lineHeight(line)
                        )
                    }

                    lastLine -> {
                        SpanLine(
                            textLayout.getLineLeft(line),
                            textLayout.getBoundingBox(70).right,
                            textLayout.lineHeight(line)
                        )
                    }

                    else -> {
                        SpanLine(
                            textLayout.getLineLeft(line),
                            textLayout.getLineRight(line),
                            textLayout.lineHeight(line)
                        )
                    }
                }
            }
        },
        modifier = modifier
    )
}

fun TextLayoutResult.lineHeight(line: Int): Float = getLineBottom(line) - getLineTop(line)

fun lazyHorizontalGradient(
    colors: List<Color>,
    translateY: () -> Float,
    lineCoords: () -> List<SpanLine>,
): Brush = object : ShaderBrush() {

    var createdShader: Shader? = null
    var _size: Size = Size.Zero
    var _translateY: Float = Float.NaN
    var _lineCoords: List<SpanLine> = emptyList()

    override fun createShader(size: Size): Shader {
        val lines = lineCoords()
        val translateY = translateY()

        if (createdShader != null && _size == size && _translateY == translateY && _lineCoords == lines) {
            return createdShader!!
        }

        val lineCount = lines.size
        if (lineCount == 0) {
            // not important, lineCoords are being evaluated.
            return LinearGradientShader(
                colors = colors,
                colorStops = null,
                from = Offset(0f, 0f),
                to = Offset(size.width, size.height),
            )
        }

        val totalWidth = lines.map { it.right - it.left }.sum()
        var startXDelta = 0f
        return concatenateShadersVertically(
            shaders = lines.map { (start, end, lineHeight) ->
                val startX = start - startXDelta
                val endX = startX + totalWidth
                startXDelta += end - start

                LinearGradientShader(
                    colors = colors,
                    colorStops = null,
                    from = Offset(startX, 0f),
                    to = Offset(endX, 0f),
                ) to lineHeight
            },
            translateY = translateY,
            shaderWidth = size.width.toInt(),
            shaderHeight = size.height.toInt()
        )
    }
}

private fun concatenateShadersVertically(
    shaders: List<Pair<Shader, Float>>,
    translateY: Float,
    shaderWidth: Int,
    shaderHeight: Int
): Shader {
    val compositeBitmap = createBitmap(shaderWidth, shaderHeight)
    val compositeCanvas = Canvas(compositeBitmap)

    val paint = Paint(Paint.ANTI_ALIAS_FLAG)

    var top = translateY
    shaders.forEachIndexed { index, (shader, lineHeight) ->
        paint.shader = shader
        val bottom = top + lineHeight
        val rect = RectF(0f, top, shaderWidth.toFloat(), bottom)
        compositeCanvas.drawRect(rect, paint)
        top += lineHeight
    }

    return ImageShader(compositeBitmap.asImageBitmap())
}

data class SpanLine(
    val left: Float,
    val right: Float,
    val height: Float
)