AnnotatedString is why you don't need three Text composables in a Row

Text takes a String or an AnnotatedString. The second lets one composable carry multiple styles, inline content and clickable spans — replacing the Row of Texts that breaks at large font scales.

6 min read
androidcomposekotlintext

Day 50 — AnnotatedString instead of three Text composables

Day 50 of 100 — the halfway mark, and the start of the text pillar. Text is the composable you've written most, and it has a second parameter overload that solves a problem people usually solve with layout.

The symptom

A sentence assembled out of three composables:

Row {
    Text("Signed in as ")
    Text(userName, fontWeight = FontWeight.Bold)
    Text(". Not you?")
}

It renders correctly on the device you're testing on. Then a user with a long name at 2× font scale sees the row overflow, because a Row doesn't wrap — Day 26. Wrapping it in a FlowRow fixes the overflow and breaks the baselines, so the three fragments sit at slightly different heights.

The real problem is that this is one sentence, and it's been modelled as three siblings.

Why the obvious fix fails

The obvious fix is to give up the bold and use one Text:

Text("Signed in as $userName. Not you?")

Wraps correctly, single baseline, and loses the emphasis that was the point. The next attempt is usually HTML-ish — a buildString with markup and a hand-rolled parser — which is a lot of code to reimplement something that already exists.

One Text with an AnnotatedString wraps as a sentence; three Texts in a Row do not

The actual mechanism

Text has two overloads: one takes a String, the other an AnnotatedString — a string plus ranges of styling attached to character offsets.

Text(
    buildAnnotatedString {
        append("Signed in as ")
        withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(userName) }
        append(". Not you?")
    }
)

One composable, one text layout, one baseline. It wraps as a sentence because it is a sentence, and the bold range travels with the characters wherever the line break lands.

Two style types, and the distinction matters:

  • SpanStyle — character-level: colour, weight, size, font, decoration, background. Applies to a range.
  • ParagraphStyle — block-level: alignment, line height, text direction, indent. Applies to whole paragraphs, and starting one forces a paragraph break.
buildAnnotatedString {
    withStyle(ParagraphStyle(lineHeight = 28.sp)) {
        withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append("Warning\n") }
        append("This action cannot be undone.")
    }
}

Links, without a click-position calculation

Clickable spans used to require ClickableText plus manual offset lookup. The current API makes a link an annotation:

Text(
    buildAnnotatedString {
        append("By continuing you agree to our ")
        withLink(
            LinkAnnotation.Url(
                "https://example.com/terms",
                TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary)),
            )
        ) { append("Terms of Service") }
        append(".")
    }
)

withLink handles the tap target, the styling and — importantly — the accessibility semantics, so a screen reader announces a link rather than plain text. LinkAnnotation.Clickable takes a lambda instead of a URL for in-app navigation.

This replaces a pattern worth un-learning: ClickableText with onClick = { offset -> … } and a getStringAnnotations(offset, offset) lookup. It's deprecated, and it never handled the accessibility side.

Inline content

Icons inside a paragraph — a currency symbol, a verified badge, an inline avatar — are a placeholder plus a map:

val text = buildAnnotatedString {
    append("Verified ")
    appendInlineContent("badge", "[badge]")
    append(" since 2024")
}

Text(
    text = text,
    inlineContent = mapOf(
        "badge" to InlineTextContent(
            Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)
        ) { Icon(Icons.Default.Verified, contentDescription = null) }
    ),
)

The placeholder is sized in sp, so the icon scales with the text — which a Row of Icon and Text does not, and that mismatch is visible immediately at large font scales.

The "[badge]" string is the fallback used when inline content can't render, and it's what accessibility tooling may read, so it's worth making meaningful.

Building an AnnotatedString from server markup

A common real case: the backend sends text with some markup and you need it styled. Don't write a parser — Android's own converter handles the common HTML subset:

val annotated = AnnotatedString.fromHtml(
    htmlString = serverText,
    linkStyles = TextLinkStyles(
        style = SpanStyle(color = MaterialTheme.colorScheme.primary),
    ),
)
Text(annotated)

It covers bold, italic, underline, links and a few block elements, and it produces links that carry the same accessibility semantics as withLink. For anything richer than that subset the honest answer is usually to change what the server sends rather than to grow a parser in the client.

Overflow and the parameters that interact

Three parameters that only make sense together:

Text(
    text = description,
    maxLines = 2,
    overflow = TextOverflow.Ellipsis,
    softWrap = true,
)

maxLines without overflow clips mid-glyph rather than ellipsising. overflow without maxLines does nothing in a container that can grow. TextOverflow.StartEllipsis and MiddleEllipsis exist too, and middle-ellipsis is the right choice for filenames and paths where the end carries the meaning.

For "read more", you need to know whether it actually overflowed:

var didOverflow by remember { mutableStateOf(false) }

Text(
    text = description,
    maxLines = if (expanded) Int.MAX_VALUE else 3,
    overflow = TextOverflow.Ellipsis,
    onTextLayout = { didOverflow = it.hasVisualOverflow },
)
if (didOverflow || expanded) {
    TextButton(onClick = { expanded = !expanded }) {
        Text(if (expanded) "Show less" else "Read more")
    }
}

onTextLayout gives you the TextLayoutResult — line count, overflow, per-character bounds — which is the only honest way to answer "is this text too long", since the answer depends on width, font and scale.

How to prove it

The wrapping claim is a preview, and the contrast is stark:

@Preview(widthDp = 320, fontScale = 2f)
@Composable fun SentenceWrapping() = AppTheme {
    Column {
        RowVersion(userName = "Alexandra Constantinou")
        AnnotatedVersion(userName = "Alexandra Constantinou")
    }
}

The Row overflows; the annotated version wraps mid-sentence and keeps one baseline.

For links, the check is TalkBack: swipe to the paragraph and confirm the link is announced as a link and separately actionable. A hand-rolled clickable span won't be.

Selection, and why it needs a container

Text isn't selectable by default. Wrapping a region in SelectionContainer makes everything inside it selectable as one continuous run:

SelectionContainer {
    Column {
        Text(article.title, style = MaterialTheme.typography.headlineSmall)
        Text(article.body, style = MaterialTheme.typography.bodyLarge)
    }
}

Because the container spans both, a drag from the title into the body selects across them — which is what a reader expects from an article and what three separate SelectionContainers would prevent.

DisableSelection carves out the parts that shouldn't be included:

SelectionContainer {
    Column {
        Text(article.body)
        DisableSelection { Text("Posted 3 hours ago", style = captionStyle) }
    }
}

Copying an article and getting "Posted 3 hours ago" stuck on the end is the small annoyance this prevents. On any screen a desktop or tablet user will read rather than scan — Day 34's point — a selection container is close to mandatory.

What this generalizes to

The idea is model the content, not the layout. A sentence with a bold word is one piece of content with an attribute, and expressing it as three siblings in a row means the layout has to reassemble something that was never meant to be taken apart.

That's why the annotated version handles wrapping, baselines, font scaling, text selection and screen readers correctly without asking: those all follow from it being one text run. Every framework with rich text lands here — NSAttributedString, HTML inline elements — because character-range styling is the shape the problem actually has.

Tomorrow, Day 51: paragraph layout — line breaking, hyphenation, and the text measurement that decides whether your list rows are the same height.


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