I've written my own implementation since my requirements were similar but slightly different - all I really needed was a custom ellipsis text instead of the default "..." text.
I also found that a lot of existing implementations don't work when using TextAlign.Center
, but this one does.
The core of how this works is:
- measure the rendered ellipsis
- remove characters from the text until the ellipsis fits
- render the ellipsis inline using an
AnnotatedString
Enjoy!
/**
* Render [contentText] and prevent overflow with a custom [ellipsisText] at the end.
*/
@Composable
fun CustomEllipsisText(
contentText: String,
contentTextStyle: SpanStyle,
ellipsisText: String,
ellipsisStyle: SpanStyle,
textStyle: TextStyle,
maxLines: Int,
modifier: Modifier = Modifier,
) {
fun AnnotatedString.Builder.addFullContent() =
withStyle(contentTextStyle) { append(contentText) }
fun AnnotatedString.Builder.addEllipsis() =
withStyle(ellipsisStyle) { append(ellipsisText) }
fun getFullTextAnnotatedString() =
buildAnnotatedString { addFullContent() }
var textLayoutResult by remember(contentText) { mutableStateOf<TextLayoutResult?>(null) }
var textAndEllipsis by remember(contentText) { mutableStateOf(getFullTextAnnotatedString()) }
var ellipsisSize by remember { mutableStateOf<IntSize?>(null) }
/**
* Calculate the position of the ellipsis, and how much of the text to show,
* by removing characters from the last line until the ellipsis will fit in the space
*
* @return the index of the last character in [contentText] which will show,
* to create space for the ellipsis.
* Returns null if the ellipsis will never fit.
*/
fun getLastTextChar(
ellipsisSize: IntSize,
textLayoutResult: TextLayoutResult
): Int? {
val lastLineIndex = maxLines - 1
val ellipsisWidth = ellipsisSize.width
val lineLength = textLayoutResult.getLineEnd(lastLineIndex, visibleEnd = true)
val lineEndX = textLayoutResult.getLineRight(lastLineIndex)
for (charIndex in lineLength downTo 1) {
val charRect = textLayoutResult.getBoundingBox(charIndex)
/** The space that has been created for the ellipsis to fit into */
val ellipsisAvailableWidth = lineEndX - charRect.left
if (ellipsisAvailableWidth > ellipsisWidth) {
return charIndex
}
}
return null
}
LaunchedEffect(contentText, textLayoutResult, ellipsisSize) {
// Locals to allow for smart-casting
val ellipsisSizeLocal = ellipsisSize ?: return@LaunchedEffect
val textLayoutResultLocal = textLayoutResult ?: return@LaunchedEffect
val isOverflowed = textLayoutResultLocal.hasVisualOverflow
if (isOverflowed) {
// We need to show the ellipsis
val lastCharIndex =
getLastTextChar(ellipsisSizeLocal, textLayoutResultLocal) ?: return@LaunchedEffect
val clippedText = contentText.substring(startIndex = 0, endIndex = lastCharIndex)
textAndEllipsis = buildAnnotatedString {
withStyle(contentTextStyle) { append(clippedText) }
addEllipsis()
}
} else {
textAndEllipsis = getFullTextAnnotatedString()
}
}
Box(modifier = modifier) {
Text(
text = textAndEllipsis,
style = textStyle,
maxLines = maxLines,
onTextLayout = {
// Only update if not previously measured
// This prevents recalculating sizing for the ellipsis
// once the ellipsis is already showing,
// preventing an infinite loop
if (textLayoutResult == null) {
textLayoutResult = it
}
},
// Use alpha to hide the text while measuring -
// This way you don't get a flicker when the text updates
modifier = Modifier.alpha(if (textLayoutResult == null) 0f else 1f)
)
// Render ellipsis so it can be measured, but not displayed
val ellipsisAnnotatedString = buildAnnotatedString { addEllipsis() }
Text(
text = ellipsisAnnotatedString,
style = textStyle,
// Measure the size of the ellipsis text,
// so we know how much space to create for it
onTextLayout = { ellipsisSize = it.size },
// Always hidden, only here for measuring
modifier = Modifier.alpha(0f)
)
}
}
text.dropLast(showMoreText.length)
line works if the size of n characters intext
is the same as the size of n characters inshowMoreText
- but unfortunately this is rarely the case in practise. The result is that often either too few or too many characters intext
are removed, meaning thatshowMoreText
will either have too much space, or not enough, and will therefore be clipped itself – Tirol