Repository navigation
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.lineHeighttall 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 aLazyColumn.
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. |
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.
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 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)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 = "")-
altTextstands for the item in semantics and when it's copied, and is shown as text whenidisn'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.
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)
}-
measuresetsmetrics.width,metrics.ascent(above the baseline) andmetrics.descent(below), in pixels.painthas the size and typeface of the surrounding text. -
drawdraws with the left edge atxand the baseline atbaseline.paint.coloris 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.
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)
}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.
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 fromLocalTextSelectionColors, whichMaterialThemeprovides. - 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.
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)
}
}
}
}-
BetterTextreportsFirstBaselineandLastBaseline, so it aligns in aRowwithModifier.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.