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
- 6,091
 - 5
 - 54
 - 79
 
3 Answers
It is not officially supported yet, keep an eye on this issue.
For now, 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:
- Make sure the ellipsis is necessary, given all the modifiers applied to the text.
 - 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:

- 67,741
 - 15
 - 184
 - 220
 
- 
                    Thats a lot of code. I hope they will provide this method soon. – Rafael Sep 07 '21 at 12:27
 - 
                    1@Rafael sure that is. If the solution was easy, they would have implemented it by now. And when they do, this question will become meaningless=) – Phil Dukhov Sep 07 '21 at 12:57
 - 
                    Worth mentioning the code dos no relayout on resize of the element, only on text change. – MrStahlfelge May 11 '22 at 08:28
 - 
                    3@MrStahlfelge I've updated my answer to support size change – Phil Dukhov May 11 '22 at 09:40
 - 
                    2Wow, I just wanted to make a remark so others are prepared as I found myself wondering why it did not work and did not expect you to improve the solution. Have much thanks! – MrStahlfelge May 12 '22 at 11:53
 
Since TextView already supports ellipsize in the middle you can just wrap it in compose using AndroidView
AndroidView(
  factory = { context ->
    TextView(context).apply {
      maxLines = 1
      ellipsize = MIDDLE
    }
  },
  update = { it.text = "A looooooooooong text" }
)
- 6,958
 - 2
 - 31
 - 42
 
- 
                    This will only with Compose for Android. Won't work with Compose for Desktop. – vovahost Oct 02 '22 at 19:08
 
There is currently no specific function in Compose yet.
A possible approach is to process the string yourself before using it, with the kotlin functions.
val word = "4gh45g43hbh4bh6b64" //put your string here
val chunks = word.chunked((word.count().toDouble()/2).roundToInt())
val midEllipsis = "${chunks[0]}…${chunks[1]}"
println(midEllipsis) 
I use the chunked function to divide the string into an array of strings, which will always be two because as a parameter I give it the size of the string divided by 2 and rounded up.
Result : 4gh45g43h…bh4bh6b64
To use the .roundToInt() function you need the following import
import kotlin.math.roundToInt
- 2,377
 - 7
 - 20
 - 39
 
- 
                    1Currently we use similar approach to handle long text. `"${title.take(characterCount)}...${title.takeLast(characterCount)}"` . Since our text vary in length we pre-check its length before running this function. – Rafael Sep 07 '21 at 12:30
 - 
                    Yes it's fine, with my code you will always have two half of the word because you `count()` the characters and then divide by 2. How do you handle an odd number of characters with your code? Anyway I hope they add something soon, star the [issue](https://issuetracker.google.com/issues/185418980) posted by Philip – Stefano Sansone Sep 07 '21 at 12:37
 -