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,
-
Annotate my text that will be underlined.
-
Calculate layout coordinates for the underlined text.
-
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 (), I’m using a custom implementation that iterates through each line and reads their bounds using TextLayoutResult#getLineLeft/Top/Right/Bottom()
APIs. Here’s if anyone’s curious.
Once the coordinates were found, it was time to draw squiggles. I was able to pretty much 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 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)
)
. 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 ().
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: