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:

    @Composable
    fun ExpandableText(
        text: String,
        modifier: Modifier = Modifier,
        minimizedMaxLines: Int = 1,
    ) {
        var cutText by remember(text) { mutableStateOf<String?>(null) }
        var expanded by remember { mutableStateOf(false) }
        val textLayoutResultState = remember { mutableStateOf<TextLayoutResult?>(null) }
        val seeMoreSizeState = remember { mutableStateOf<IntSize?>(null) }
        val seeMoreOffsetState = remember { mutableStateOf<Offset?>(null) }
    
        // getting raw values for smart cast
        val textLayoutResult = textLayoutResultState.value
        val seeMoreSize = seeMoreSizeState.value
        val seeMoreOffset = seeMoreOffsetState.value
    
        LaunchedEffect(text, expanded, textLayoutResult, seeMoreSize) {
            val lastLineIndex = minimizedMaxLines - 1
            if (!expanded && textLayoutResult != null && seeMoreSize != null
                && lastLineIndex + 1 == textLayoutResult.lineCount
                && textLayoutResult.isLineEllipsized(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
                )
                seeMoreOffsetState.value = Offset(charRect.left, charRect.bottom - seeMoreSize.height)
                cutText = text.substring(startIndex = 0, endIndex = lastCharIndex)
            }
        }
        
        Box(modifier) {
            Text(
                text = cutText ?: text,
                maxLines = if (expanded) Int.MAX_VALUE else minimizedMaxLines,
                overflow = TextOverflow.Ellipsis,
                onTextLayout = { textLayoutResultState.value = it },
            )
            if (!expanded) {
                val density = LocalDensity.current
                Text(
                    "... See more",
                    onTextLayout = { seeMoreSizeState.value = it.size },
                    modifier = Modifier
                        .then(
                            if (seeMoreOffset != null)
                                Modifier.offset(
                                    x = with(density) { seeMoreOffset.x.toDp() },
                                    y = with(density) { seeMoreOffset.y.toDp() },
                                )
                            else
                                Modifier
                        )
                        .clickable {
                            expanded = true
                            cutText = null
                        }
                        .alpha(if (seeMoreOffset != null) 1f else 0f)
                )
            }
        }
    }