Drawing custom text spans in Compose UI

While exploring how text paragraphs are rendered in Compose UI, I nerd sniped myself into porting squiggly underlines from Sam Ruston’s Buzzkill app. Sam’s animation was implemented using TextView custom spans, but Compose UI does not offer any alternatives for them yet. While our friends at Google are prototyping text modifiers (first, second), I figured I could draw them manually in the meantime.

My plan was to,

  1. Annotate my text that will be underlined.

  2. Calculate layout coordinates for the underlined text.

  3. Draw squiggles between those coordinates directly on Canvas.

I was able to start by using withAnnotation() offered by buildAnnotatedString(). Unlike SpannedString in the View land, AnnotatedString does not accept arbitrary objects for drawing custom spans. This will certainly be a roadblock for custom spans that require metadata, but plain strings were sufficient for my usecase.

val text = buildAnnotatedString {
  append("I'll be alright as long as there's light from a ")
  withAnnotation("squiggles", annotation = "ignored") {
    withStyle(SpanStyle(color = Color.Purple)) {
      append("neon moon")
    }
  }
}

My next step was to find layout coordinates for my annotated text so that I could decorate them with underlines. I was able to use TextLayoutResult for this:

var onDraw: DrawScope.() -> Unit by remember { mutableStateOf({}) }

Text(
  modifier = Modifier.drawBehind { onDraw() }
  text = text,
  onTextLayout = { layoutResult ->
    val annotation = text.getStringAnnotations("squiggles", …).first()
    val textBounds = layoutResult.getBoundingBoxes(annotation.start..annotation.end)
    onDraw = {
      for (bound in textBounds) {
        val underline = bound.copy(top = bound.bottom - 4.sp.toPx())
        drawRect(
          color = Color.Purple,
          topLeft = underline.topLeft,
          size = underline.size,
        )
      }
    }
  }
)

TextLayoutResult#getBoundingBoxes() actually doesn’t exist yet. Until Google adds an official API (issuetracker), I’m using a custom implementation that iterates through each line and reads their bounds using TextLayoutResult#getLineLeft/Top/Right/Bottom() APIs. Here’s my code if anyone’s curious.

Once the coordinates were found, it was time to draw squiggles. I was able to pretty much copy-paste Sam Ruston’s maths that builds waves by connecting points generated using Math.sin().

 drawPath(
   color = Color.Purple,
-  topLeft = underline.topLeft,
-  size = underline.size
+  path = buildSquigglesFor(bound)
 )
/**
 *       _....._                                     _....._         ᐃ
 *    ,="       "=.                               ,="       "=.   amplitude
 *  ,"             ".                           ,"             ".    │
 *,"                 ".,                     ,,"                 "., ᐁ
 *""""""""""|""""""""""|."""""""""|""""""""".|""""""""""|""""""""""|
 *                       ".               ."
 *                         "._         _,"
 *                            "-.....-"
 *ᐊ--------------- Wavelength --------------ᐅ
 */
private fun DrawScope.buildSquigglesFor(bound: Rect, waveOffset: Float = 0f): Path {
  val wavelength = 16.sp.toPx()
  val amplitude = 1.sp.toPx()
  
  val segmentWidth = wavelength / SEGMENTS_PER_WAVELENGTH
  val numOfPoints = ceil(bound.width / segmentWidth).toInt() + 1
  
  // I'm creating a new Path object for brevity, but you'll 
  // want to cache it somewhere to reuse across draw frames.
  return Path().apply {
    var pointX = bound.left
    for (point in 0..numOfPoints) {
      val proportionOfWavelength = (pointX - bound.left) / wavelength
      val radiansX = proportionOfWavelength * 2 * Math.PI
      val offsetY = bound.bottom + (sin(radiansX + waveOffset) * amplitude)

      when (point) {
        0 -> moveTo(pointX, offsetY)
        else -> lineTo(pointX, offsetY)
      }
      pointX += segmentWidth
    }
  }
}

For animating the squiggles, I noticed that Sam’s code was invalidating the Canvas on every animation frame infinitely. I was able to use InfiniteTransition to mimic that in Compose UI:

val animationProgress by rememberInfiniteTransition().animateFloat(
  initialValue = 0f,
  targetValue = 1f,
  animationSpec = infiniteRepeatable(
    animation = tween(1_000, easing = LinearEasing),
    repeatMode = Restart
  )
)
 drawPath(
   color = Color.Purple,
-  path = buildSquigglesFor(bound)   
+  path = buildSquigglesFor(bound, waveOffset = 2 * Math.PI * animationProgress)
 )

Another interaction that I really like in Buzzkill is how errors are indicated by changing squiggles to red. I was able to recreate that in one line using animate*AsState():

- val waveLength = 16.sp
+ val waveLength by animateSpAsState(targetValue = if (isError) 120.sp else 16.sp)

That was fun!

The above code can be used for so much more. For example, I was also able to draw text with round corner backgrounds, which is something Compose UI should really support out of the box (issuetracker).

drawRoundRect(
  color = Color.Purple.copy(alpha = 0.3f),
  topLeft = bound.topLeft,
  size = bound.size,
  style = Fill
)
drawRoundRect(
  color = Color.Purple.copy(alpha = 0.6f),
  topLeft = bound.topLeft,
  size = bound.size,
  style = Stroke(width = 1.sp.toPx())
)

If you’re interested in adding squiggly underlines or round corner backgrounds to your app, I packaged all my code into a tiny library that you can (almost) drop into your existing code:

https://github.com/saket/ExtendedSpans.