Skip to content

BetterText

re-ovo edited this page Oct 28, 2026 · 1 revision

BetterText

Module: com.github.re-ovo.bettertext:text, package me.rerere.bettertext.text.

BetterText replaces BasicText for text with inline content. It lays the text out with Android's StaticLayout, so:

  • inline items sit on the text baseline and are measured and drawn with the surrounding text's paint;
  • each line is at least style.lineHeight tall and grows to fit taller items or larger spans, instead of overlapping its neighbors;
  • inside a BetterSelectionContainer, texts can be selected together, including across items of a LazyColumn.

Basic usage

BetterText("Hello, world")

BetterText(
    text = buildAnnotatedString {
        append("Plain, ")
        withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append("bold") }
        append(" and ")
        withStyle(SpanStyle(fontSize = 1.5.em, color = Color.Red)) { append("large") }
    },
    style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
    maxLines = 3,
)

Unlike Material's Text, BetterText doesn't read LocalTextStyle or LocalContentColor: pass the style, including its color, explicitly. A style without a color draws black, and one without a font size uses 14 sp.

Parameter Meaning
text A String or an AnnotatedString.
style Font, size, color, line height, alignment and so on.
inlineContent Inline items by id, see Inline content. Only on the AnnotatedString overload.
maxLines Lines beyond this are cut off with an ellipsis. Must be positive.

Supported styles

From TextStyle: color, fontSize, fontFamily, fontWeight, fontStyle, fontSynthesis, letterSpacing, textDecoration, fontFeatureSettings, background, lineHeight and textAlign (including Justify).

From SpanStyle: color, fontSize (sp or em), fontFamily, fontWeight, fontStyle, letterSpacing, background, textDecoration, baselineShift and fontFeatureSettings.

Other attributes, such as shadows, brushes and paragraph styles inside the AnnotatedString, are ignored.

Line height

lineHeight is a minimum. The extra space is split above and below the text, like CSS half-leading, and a line containing a tall formula or a larger span grows to fit it. This is the main difference from Compose, where every line gets exactly lineHeight and tall content overlaps the lines around it (427657659).

Links

Links use Compose's LinkAnnotation. Tapping one calls its linkInteractionListener, or opens its URL with LocalUriHandler when it has none.

val text = buildAnnotatedString {
    append("See ")
    withLink(
        LinkAnnotation.Url(
            url = "https://github.com/re-ovo/bettertext",
            styles = TextLinkStyles(SpanStyle(color = Color.Blue, textDecoration = TextDecoration.Underline)),
        ),
    ) { append("the repository") }
    append(".")
}
BetterText(text)

Inline content

An inline item is added to the text with appendInline(id, altText) and resolved through the inlineContent map:

fun AnnotatedString.Builder.appendInline(id: String, altText: String = "")
  • altText stands for the item in semantics and when it's copied, and is shown as text when id isn't in the map.
  • Remember the map. A new map prepares the text again.

There are two kinds of items, both implementing the sealed BetterInline.

DrawnInline: drawn with the text

A DrawnInline is measured and drawn on the text's canvas with the paint of the surrounding text, so it follows its font size and color, and costs no extra composable. Formulas from the latex module (LatexInline) are drawn inline items; see LaTeX.

abstract class DrawnInline : BetterInline {
    open val segmentCount: Int get() = 1
    abstract fun measure(segment: Int, paint: Paint, metrics: InlineMetrics)
    abstract fun draw(segment: Int, canvas: Canvas, x: Float, baseline: Float, paint: Paint)
}
  • measure sets metrics.width, metrics.ascent (above the baseline) and metrics.descent (below), in pixels. paint has the size and typeface of the surrounding text.
  • draw draws with the left edge at x and the baseline at baseline. paint.color is the surrounding text color; you may modify the paint.
  • An item may have several unbreakable segments (segmentCount). Lines may wrap between them, which is how a long formula breaks after its relations and operators.

An example: a color swatch, sized in em of the surrounding text and centered on it.

class ColorSwatch(color: Color, private val size: Float = 0.8f, private val margin: Float = 0.15f) : DrawnInline() {
    private val argb = color.toArgb()

    override fun measure(segment: Int, paint: Paint, metrics: InlineMetrics) {
        val s = size * paint.textSize
        val center = (paint.ascent() + paint.descent()) / 2f
        metrics.width = s + 2 * margin * paint.textSize
        metrics.ascent = s / 2 - center
        metrics.descent = s / 2 + center
    }

    override fun draw(segment: Int, canvas: Canvas, x: Float, baseline: Float, paint: Paint) {
        val s = size * paint.textSize
        val left = x + margin * paint.textSize
        val top = baseline + (paint.ascent() + paint.descent()) / 2f - s / 2
        paint.style = Paint.Style.FILL
        paint.color = argb
        canvas.drawRect(left, top, left + s, top + s, paint)
    }
}

The gallery's gallery/text/ColorSwatch.kt is a fuller version with rounded corners and an outline.

ComposableInline: a composable in the text

A ComposableInline is a composable measured as a child of the text's layout and placed over its span, for interactive items such as citation tags. It's measured with the text's maximum width and never wraps.

class ComposableInline(
    val align: InlineAlign = InlineAlign.Baseline,
    val content: @Composable () -> Unit,
)

InlineAlign.Baseline puts the composable's first baseline on the text baseline (its bottom if it has none); InlineAlign.Center centers it on the middle of the surrounding text.

@Composable
fun Cited(onCite: (Int) -> Unit) {
    val chip = MaterialTheme.colorScheme.primaryContainer
    val inlineContent = remember(chip) {
        mapOf(
            "cite-1" to ComposableInline(InlineAlign.Center) {
                Box(
                    Modifier
                        .padding(horizontal = 2.dp)
                        .clip(RoundedCornerShape(4.dp))
                        .background(chip)
                        .clickable { onCite(1) }
                        .padding(horizontal = 5.dp),
                ) {
                    Text("1", style = MaterialTheme.typography.labelSmall)
                }
            },
        )
    }
    val text = remember {
        buildAnnotatedString {
            append("Compose draws text with Skia on Android")
            appendInline("cite-1", "[1]")
            append(".")
        }
    }
    BetterText(text, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge)
}

Decorations

An item with an empty altText is a decoration, such as a swatch before a color code:

buildAnnotatedString {
    appendInline("swatch", altText = "")
    withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append("#8B0000") }
}

A decoration copies as nothing, a span that ends where it is doesn't style it, and it stays on the same line as the text that follows it.

Selection

BetterSelectionContainer makes the BetterTexts inside it selectable together, like Compose's SelectionContainer:

BetterSelectionContainer {
    Column {
        BetterText("First paragraph")
        BetterText("Second paragraph")
    }
}
  • Long press selects a word, or a whole inline item; dragging extends the selection across texts. The handles can then be dragged.
  • Inline items are selected whole and copied as their alt text. Texts are joined with a newline.
  • The toolbar comes from LocalTextToolbar (Copy, and Select all when not everything is selected); the highlight color from LocalTextSelectionColors, which MaterialTheme provides.
  • A tap that nothing else handles, or the container losing focus, clears the selection.
  • Dragging near the edge of the visible content scrolls what is cut off there: the page around the container, a list inside it, or a code block or table that scrolls sideways.

Only BetterText takes part. Compose's Text, and the standalone Latex and LatexBlock composables, are not selectable inside a BetterSelectionContainer.

Selecting across a lazy list

Wrap the list in one container to let the selection span its items. Texts with part of the selection are pinned, so the list doesn't dispose them when they scroll out of view.

When the list scrolls behind app bars, pass the scaffold's padding as obscuredPadding: handles under it are hidden, and dragging to its edge scrolls the list.

Scaffold(topBar = { TopAppBar(title = { Text("Chat") }) }) { padding ->
    BetterSelectionContainer(Modifier.fillMaxSize(), obscuredPadding = padding) {
        LazyColumn(contentPadding = padding) {
            items(messages) { message ->
                Markdown(content = message.text, style = style, components = components)
            }
        }
    }
}

Notes

  • BetterText reports FirstBaseline and LastBaseline, so it aligns in a Row with Modifier.alignByBaseline().
  • Its width is the text's own width clamped to the constraints, like BasicText.
  • The text is exposed to accessibility services as its semantics text.

Clone this wiki locally