| name | expandable-and-linkified-text |
| description | Clamp long text to a few lines with a more/less affordance driven by measured overflow instead of a character count, and make timestamps or URLs inside the same text tappable. Use when "more" shows on text that already fits, never shows on text that does not, gets truncated along with the text, or when tapping a link expands the block instead of following the link. |
Expandable text, and tappable spans inside it
Two features that read as one and share no parts: clamping to N lines with a more/less affordance is
maxLines plus onTextLayout { hasVisualOverflow }; tappable substrings are buildAnnotatedString
plus pushStringAnnotation, hit-tested by character offset. They compete for the same tap, so
decide what a tap on the body means before writing either.
Clamping on measured overflow
Only the layout pass knows whether the text overflows — the same string wraps differently per
width, font, text scale and translation. Ask it, and re-clamp by hand so the affordance is not
itself truncated:
var isExpanded by remember { mutableStateOf(false) }
var clickable by remember { mutableStateOf(false) }
var lastCharIndex by remember { mutableIntStateOf(0) }
Box(Modifier.clickable { isExpanded = !isExpanded }) {
Text(
text = buildAnnotatedString {
(clickable) {
(isExpanded) {
append(text)
withStyle(showLessStyle) { append(showLessText) }
} {
adjusted = text.substring(startIndex = , endIndex = lastCharIndex)
.dropLast(showMoreText.length)
.dropLastWhile { it.isWhitespace() || it == }
append(adjusted)
withStyle(showMoreStyle) { append(showMoreText) }
}
} {
append(text)
}
},
maxLines = (isExpanded) .MAX_VALUE collapsedMaxLine,
onTextLayout = { result ->
(!isExpanded && result.hasVisualOverflow) {
clickable =
lastCharIndex = runCatching { result.getLineEnd(collapsedMaxLine - ) }
.getOrDefault(text.length - )
}
},
)
}