androidkotlinandroid-jetpack-composeandroid-jetpack-compose-text

Expandable Text in Jetpack Compose


so I am using a Text() composable like so:

Text(
    text = "this is some sample text that is long and so it is 
            ellipsized",
    maxLines = 1,
    overflow = TextOverflow.Ellipsis
)

and it ellipsizes the text properly:

enter image description here

The issue is that I want a See More tag at the end of the ellipsis, prompting the user to expand the visible text box. How would I go about adding that?

enter image description here


Solution

  • To solve this you need to use onTextLayout to get TextLayoutResult: it contains all info about the state of drawn text.

    Making it work for multiple lines is a tricky task. To do that you need to calculate sizes of both ellipsized text and "... See more" text, then, when you have both values you need to calculate how much text needs to be removed so "... See more" fits perfectly at the end of line.

    The last item is SubcomposeLayout, it would allow you making the calculations in one layout cycle, so your UI won't jump.

    @Composable
    fun ExpandableText(
        text: String,
        modifier: Modifier = Modifier,
        minimizedMaxLines: Int = 1,
    ) {
        var expanded by rememberSaveable { mutableStateOf(false) }
        SubcomposeLayout(modifier) { constraints ->
            var textLayoutResultVar: TextLayoutResult? = null
            var seeMoreSizeVar: IntSize? = null
            var textComposable = subcompose("full text") {
                Text(
                    text = text,
                    onTextLayout = { textLayoutResultVar = it },
                )
            }.first().measure(constraints)
            val seeMoreText = subcompose("see more") {
                Text(
                    "... See more",
                    onTextLayout = { seeMoreSizeVar = it.size },
                    modifier = Modifier
                        .clickable {
                            expanded = true
                        }
                )
            }.first().measure(Constraints())
    
            // allowing smart cast
            val textLayoutResult = textLayoutResultVar
            val seeMoreSize = seeMoreSizeVar
    
            val textWidth = textComposable.width
            val lastLineIndex = minimizedMaxLines - 1
            val seeMoreOffset: Offset?
            if (!expanded && textLayoutResult != null && seeMoreSize != null
                && textLayoutResult.lineCount > lastLineIndex
            ) {
                var lastCharIndex = textLayoutResult.getLineEnd(lastLineIndex, visibleEnd = true) + 1
                var charRect: Rect
                do {
                    lastCharIndex -= 1
                    charRect = textLayoutResult.getCursorRect(lastCharIndex)
                } while (
                    charRect.left > textLayoutResult.size.width - seeMoreSize.width
                )
                seeMoreOffset = Offset(charRect.left, charRect.bottom - seeMoreSize.height)
                textComposable = subcompose("cutText") {
                    Text(
                        text = text.substring(startIndex = 0, endIndex = lastCharIndex),
                    )
                }.first().measure(constraints)
            } else {
                seeMoreOffset = null
            }
    
            layout(textWidth, textComposable.height) {
                textComposable.place(0, 0)
                if (seeMoreOffset != null) {
                    seeMoreText.place(
                        x = seeMoreOffset.x.toInt(),
                        y = seeMoreOffset.y.toInt()
                    )
                }
            }
        }
    }