Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to make middle ellipsis in Text with Jetpack Compose

I need to make Middle Ellipsis in Jetpack Compose Text. As far as I see there is only Clip, Ellipsis and Visible options for TextOverflow. Something like this: 4gh45g43h...bh4bh6b64

like image 364
Rafael Avatar asked Dec 08 '25 16:12

Rafael


1 Answers

As of Compose 1.8.0, this is available via TextOverflow.StartEllipsis|MiddleEllipsis|Ellipsis

Text("Long text", overflow = TextOverflow.MiddleEllipsis)

For older versions, you can use the following method. I use SubcomposeLayout to get onTextLayout result without actually drawing the initial text.

It takes so much code and calculations to:

  1. Make sure the ellipsis is necessary, given all the modifiers applied to the text.
  2. Make the size of the left and right parts as close to each other as possible, based on the size of the characters, not just their number.
@Composable
fun MiddleEllipsisText(
    text: String,
    modifier: Modifier = Modifier,
    color: Color = Color.Unspecified,
    fontSize: TextUnit = TextUnit.Unspecified,
    fontStyle: FontStyle? = null,
    fontWeight: FontWeight? = null,
    fontFamily: FontFamily? = null,
    letterSpacing: TextUnit = TextUnit.Unspecified,
    textDecoration: TextDecoration? = null,
    textAlign: TextAlign? = null,
    lineHeight: TextUnit = TextUnit.Unspecified,
    softWrap: Boolean = true,
    onTextLayout: (TextLayoutResult) -> Unit = {},
    style: TextStyle = LocalTextStyle.current,
) {
    // some letters, like "r", will have less width when placed right before "."
    // adding a space to prevent such case
    val layoutText = remember(text) { "$text $ellipsisText" }
    val textLayoutResultState = remember(layoutText) {
        mutableStateOf<TextLayoutResult?>(null)
    }
    SubcomposeLayout(modifier) { constraints ->
        // result is ignored - we only need to fill our textLayoutResult
        subcompose("measure") {
            Text(
                text = layoutText,
                color = color,
                fontSize = fontSize,
                fontStyle = fontStyle,
                fontWeight = fontWeight,
                fontFamily = fontFamily,
                letterSpacing = letterSpacing,
                textDecoration = textDecoration,
                textAlign = textAlign,
                lineHeight = lineHeight,
                softWrap = softWrap,
                maxLines = 1,
                onTextLayout = { textLayoutResultState.value = it },
                style = style,
            )
        }.first().measure(Constraints())
        // to allow smart cast
        val textLayoutResult = textLayoutResultState.value
            ?: // shouldn't happen - onTextLayout is called before subcompose finishes
            return@SubcomposeLayout layout(0, 0) {}
        val placeable = subcompose("visible") {
            val finalText = remember(text, textLayoutResult, constraints.maxWidth) {
                if (text.isEmpty() || textLayoutResult.getBoundingBox(text.indices.last).right <= constraints.maxWidth) {
                    // text not including ellipsis fits on the first line.
                    return@remember text
                }

                val ellipsisWidth = layoutText.indices.toList()
                    .takeLast(ellipsisCharactersCount)
                    .let widthLet@{ indices ->
                        // fix this bug: https://issuetracker.google.com/issues/197146630
                        // in this case width is invalid
                        for (i in indices) {
                            val width = textLayoutResult.getBoundingBox(i).width
                            if (width > 0) {
                                return@widthLet width * ellipsisCharactersCount
                            }
                        }
                        // this should not happen, because
                        // this error occurs only for the last character in the string
                        throw IllegalStateException("all ellipsis chars have invalid width")
                    }
                val availableWidth = constraints.maxWidth - ellipsisWidth
                val startCounter = BoundCounter(text, textLayoutResult) { it }
                val endCounter = BoundCounter(text, textLayoutResult) { text.indices.last - it }

                while (availableWidth - startCounter.width - endCounter.width > 0) {
                    val possibleEndWidth = endCounter.widthWithNextChar()
                    if (
                        startCounter.width >= possibleEndWidth
                        && availableWidth - startCounter.width - possibleEndWidth >= 0
                    ) {
                        endCounter.addNextChar()
                    } else if (availableWidth - startCounter.widthWithNextChar() - endCounter.width >= 0) {
                        startCounter.addNextChar()
                    } else {
                        break
                    }
                }
                startCounter.string.trimEnd() + ellipsisText + endCounter.string.reversed().trimStart()
            }
            Text(
                text = finalText,
                color = color,
                fontSize = fontSize,
                fontStyle = fontStyle,
                fontWeight = fontWeight,
                fontFamily = fontFamily,
                letterSpacing = letterSpacing,
                textDecoration = textDecoration,
                textAlign = textAlign,
                lineHeight = lineHeight,
                softWrap = softWrap,
                onTextLayout = onTextLayout,
                style = style,
            )
        }[0].measure(constraints)
        layout(placeable.width, placeable.height) {
            placeable.place(0, 0)
        }
    }
}

private const val ellipsisCharactersCount = 3
private const val ellipsisCharacter = '.'
private val ellipsisText = List(ellipsisCharactersCount) { ellipsisCharacter }.joinToString(separator = "")

private class BoundCounter(
    private val text: String,
    private val textLayoutResult: TextLayoutResult,
    private val charPosition: (Int) -> Int,
) {
    var string = ""
        private set
    var width = 0f
        private set

    private var _nextCharWidth: Float? = null
    private var invalidCharsCount = 0

    fun widthWithNextChar(): Float =
        width + nextCharWidth()

    private fun nextCharWidth(): Float =
        _nextCharWidth ?: run {
            var boundingBox: Rect
            // invalidCharsCount fixes this bug: https://issuetracker.google.com/issues/197146630
            invalidCharsCount--
            do {
                boundingBox = textLayoutResult
                    .getBoundingBox(charPosition(string.count() + ++invalidCharsCount))
            } while (boundingBox.right == 0f)
            _nextCharWidth = boundingBox.width
            boundingBox.width
        }

    fun addNextChar() {
        string += text[charPosition(string.count())]
        width += nextCharWidth()
        _nextCharWidth = null
    }
}

My testing code:

val text = remember { LoremIpsum(100).values.first().replace("\n", " ") }
var length by remember { mutableStateOf(77) }
var width by remember { mutableStateOf(0.5f) }
Column {
    MiddleEllipsisText(
        text.take(length),
        fontSize = 30.sp,
        modifier = Modifier
            .background(Color.LightGray)
            .padding(10.dp)
            .fillMaxWidth(width)
    )
    Slider(
        value = length.toFloat(),
        onValueChange = { length = it.roundToInt() },
        valueRange = 2f..text.length.toFloat()
    )
    Slider(
        value = width,
        onValueChange = { width = it },
    )
}

Result:

like image 52
Philip Dukhov Avatar answered Dec 10 '25 23:12

Philip Dukhov