Expandable Text in Jetpack Compose
Asked Answered
P

12

21

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

Pyrite answered 23/8, 2021 at 14:38 Comment(0)
S
20

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)
            )
        }
    }
}

Saleem answered 23/8, 2021 at 15:2 Comment(0)
D
18

My simple implementation, hope it useful:

const val DEFAULT_MINIMUM_TEXT_LINE = 3

@Composable
fun ExpandableText(
    modifier: Modifier = Modifier,
    textModifier: Modifier = Modifier,
    style: TextStyle = LocalTextStyle.current,
    fontStyle: FontStyle? = null,
    text: String,
    collapsedMaxLine: Int = DEFAULT_MINIMUM_TEXT_LINE,
    showMoreText: String = "... Show More",
    showMoreStyle: SpanStyle = SpanStyle(fontWeight = FontWeight.W500),
    showLessText: String = " Show Less",
    showLessStyle: SpanStyle = showMoreStyle,
    textAlign: TextAlign? = null
) {
    var isExpanded by remember { mutableStateOf(false) }
    var clickable by remember { mutableStateOf(false) }
    var lastCharIndex by remember { mutableStateOf(0) }
    Box(modifier = Modifier
        .clickable(clickable) {
            isExpanded = !isExpanded
        }
        .then(modifier)
    ) {
        Text(
            modifier = textModifier
                .fillMaxWidth()
                .animateContentSize(),
            text = buildAnnotatedString {
                if (clickable) {
                    if (isExpanded) {
                        append(text)
                        withStyle(style = showLessStyle) { append(showLessText) }
                    } else {
                        val adjustText = text.substring(startIndex = 0, endIndex = lastCharIndex)
                            .dropLast(showMoreText.length)
                            .dropLastWhile { Character.isWhitespace(it) || it == '.' }
                        append(adjustText)
                        withStyle(style = showMoreStyle) { append(showMoreText) }
                    }
                } else {
                    append(text)
                }
            },
            maxLines = if (isExpanded) Int.MAX_VALUE else collapsedMaxLine,
            fontStyle = fontStyle,
            onTextLayout = { textLayoutResult ->
                if (!isExpanded && textLayoutResult.hasVisualOverflow) {
                    clickable = true
                    lastCharIndex = textLayoutResult.getLineEnd(collapsedMaxLine - 1)
                }
            },
            style = style,
            textAlign = textAlign
        )
    }

}
Dentifrice answered 14/7, 2022 at 14:23 Comment(1)
This solution is almost right, but it makes one fatal assumption that breaks it in many cases - it assumes that the size of every character is the same. The text.dropLast(showMoreText.length) line works if the size of n characters in text is the same as the size of n characters in showMoreText - but unfortunately this is rarely the case in practise. The result is that often either too few or too many characters in text are removed, meaning that showMoreText will either have too much space, or not enough, and will therefore be clipped itselfTirol
S
3

A simple implementation:

enter image description here

@Composable
fun ExpandableText(
    modifier: Modifier = Modifier,
    text: String,
    minimizedMaxLines: Int,
    style: TextStyle
) {
    var expanded by remember { mutableStateOf(false) }
    var hasVisualOverflow by remember { mutableStateOf(false) }
    Box(modifier = modifier) {
        Text(
            text = text,
            maxLines = if (expanded) Int.MAX_VALUE else minimizedMaxLines,
            onTextLayout = { hasVisualOverflow = it.hasVisualOverflow },
            style = style
        )
        if (hasVisualOverflow) {
            Row(
                modifier = Modifier.align(Alignment.BottomEnd),
                verticalAlignment = Alignment.Bottom
            ) {
                val lineHeightDp: Dp = with(LocalDensity.current) { style.lineHeight.toDp() }
                Spacer(
                    modifier = Modifier
                        .width(48.dp)
                        .height(lineHeightDp)
                        .background(
                            brush = Brush.horizontalGradient(
                                colors = listOf(Color.Transparent, Color.White)
                            )
                        )
                )
                Text(
                    modifier = Modifier
                        .background(Color.White)
                        .padding(start = 4.dp)
                        .clickable(
                            indication = null,
                            interactionSource = remember { MutableInteractionSource() },
                            onClick = { expanded = !expanded }
                        ),
                    text = "Show More",
                    color = MaterialTheme.colors.primary,
                    style = style
                )
            }
        }
    }
}
Shunt answered 27/1, 2022 at 13:8 Comment(0)
U
2
@Composable
fun ExpandedText(
    text: String,
    expandedText: String,
    expandedTextButton: String,
    shrinkTextButton: String,
    modifier: Modifier = Modifier,
    softWrap: Boolean = true,
    textStyle: TextStyle = LocalTextStyle.current,
    expandedTextStyle: TextStyle = LocalTextStyle.current,
    expandedTextButtonStyle: TextStyle = LocalTextStyle.current,
    shrinkTextButtonStyle: TextStyle = LocalTextStyle.current,
) {
    
    var isExpanded by remember { mutableStateOf(false) }
    
    val textHandler = "${if (isExpanded) expandedText else text} ${if (isExpanded) shrinkTextButton else expandedTextButton}"
    
    val annotatedString = buildAnnotatedString {
        withStyle(
            if (isExpanded) expandedTextStyle.toSpanStyle() else textStyle.toSpanStyle()
        ) {
            append(if (isExpanded) expandedText else text)
        }
        
        append("  ")
        
        withStyle(
            if (isExpanded) shrinkTextButtonStyle.toSpanStyle() else expandedTextButtonStyle.toSpanStyle()
        ) {
            append(if (isExpanded) shrinkTextButton else expandedTextButton)
        }
        
        addStringAnnotation(
            tag = "expand_shrink_text_button",
            annotation = if (isExpanded) shrinkTextButton else expandedTextButton,
            start = textHandler.indexOf(if (isExpanded) shrinkTextButton else expandedTextButton),
            end = textHandler.indexOf(if (isExpanded) shrinkTextButton else expandedTextButton) + if (isExpanded) expandedTextButton.length else shrinkTextButton.length
        )
    }
    
    ClickableText(
        text = annotatedString,
        softWrap = softWrap,
        modifier = modifier,
        onClick = {
            annotatedString
                .getStringAnnotations(
                    "expand_shrink_text_button",
                    it,
                    it
                )
                .firstOrNull()?.let { stringAnnotation ->
                    isExpanded = stringAnnotation.item == expandedTextButton
                }
        }
    )
}

usage

ExpandedText(
            text = food.content,
            expandedText = food.contentFull,
            expandedTextButton = " more",
            shrinkTextButton = " less",
            textStyle = typographySkModernist().body1.copy(
                color = black.copy(alpha = 0.8f)
            ),
            expandedTextStyle = typographySkModernist().body1.copy(
                color = black.copy(alpha = 0.8f)
            ),
            expandedTextButtonStyle = typographySkModernist().body1.copy(
                color = orange,
            ),
            shrinkTextButtonStyle = typographySkModernist().body1.copy(
                color = orange,
            ),
            modifier = Modifier
                .padding(top = 32.dp, start = 24.dp, end = 16.dp)
        )

enter image description here

Ultun answered 12/12, 2021 at 5:55 Comment(1)
this code does not show the initial text, only expanded for meBiotin
M
2

I wanted a more Flexible one

package {packageName}.core.presentation.components

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.sp
import Constants.MINIMIZED_MAX_LINES

/**
 * @param modifier use this to add padding and such
 * @param longText is the Text that is to long and need to be displayed that has more than [minimizedMaxLines]
 * @param minimizedMaxLines (optional) the minimum amount of text lines to be visible in non-expanded state
 * @param textAlign (optional) defaults to [TextAlign.Start] unless overridden, try [TextAlign.Justify]
 * @param expandHint (optional) this text is appended to the [longText] before expanding and become clickable
 * @param shrinkHint (optional) this text is appended to the [longText] after expanding and become clickable
 * @param clickColor (optional) denotes the color of the clickable [expandHint] & [shrinkHint] strings
 * */
@Composable
fun AppExpandingText(
    modifier: Modifier = Modifier,
    longText: String,
    minimizedMaxLines: Int = 3,
    textAlign: TextAlign = TextAlign.Start,
    expandHint: String = "… Show More",
    shrinkHint: String = "… Show Less",
    clickColor: Color = Color.Unspecified
) {
    var isExpanded by remember { mutableStateOf(value = false) }
    var textLayoutResultState by remember { mutableStateOf<TextLayoutResult?>(value = null) }
    var adjustedText by remember { mutableStateOf(value = longText) }
    val overflow = textLayoutResultState?.hasVisualOverflow ?: false
    val showOverflow = remember { mutableStateOf(value = false) }
    val showMore = " $expandHint"
    val showLess = " $shrinkHint"
    
    LaunchedEffect(textLayoutResultState) {
        if (textLayoutResultState == null) return@LaunchedEffect
        if (!isExpanded && overflow) {
            showOverflow.value = true
            val lastCharIndex = textLayoutResultState!!.getLineEnd(lineIndex = minimizedMaxLines - 1)
            adjustedText = longText
                .substring(startIndex = 0, endIndex = lastCharIndex)
                .dropLast(showMore.length)
                .dropLastWhile { it == ' ' || it == '.' }
        }
    }
    val annotatedText = buildAnnotatedString {
        if (isExpanded) {
            append(longText)
            withStyle(
                style = SpanStyle(
                    color = MaterialTheme.colors.onSurface,
                    fontSize = 14.sp
                )
            ) {
                pushStringAnnotation(tag = "showLess", annotation = "showLess")
                append(showLess)
                addStyle(
                    style = SpanStyle(
                        color = clickColor,
                        fontSize = 14.sp
                    ),
                    start = longText.length,
                    end = longText.length + showMore.length
                )
                pop()
            }
        } else {
            append(adjustedText)
            withStyle(
                style = SpanStyle(
                    color = MaterialTheme.colors.onSurface,
                    fontSize = 14.sp
                )
            ) {
                if (showOverflow.value) {
                    pushStringAnnotation(tag = "showMore", annotation = "showMore")
                    append(showMore)
                    addStyle(
                        style = SpanStyle(
                            color = clickColor,
                            fontSize = 14.sp
                        ),
                        start = adjustedText.length,
                        end = adjustedText.length + showMore.length
                    )
                    pop()
                }
            }
        }
        
    }
    Box(modifier = modifier) {
        ClickableText(
            text = annotatedText,
            style = (MaterialTheme.typography.body1.copy(textAlign = textAlign)),
            maxLines = if (isExpanded) Int.MAX_VALUE else MINIMIZED_MAX_LINES,
            onTextLayout = { textLayoutResultState = it },
            onClick = { offset ->
                annotatedText.getStringAnnotations(
                    tag = "showLess",
                    start = offset,
                    end = offset + showLess.length
                ).firstOrNull()?.let {
                    isExpanded = !isExpanded
                }
                annotatedText.getStringAnnotations(
                    tag = "showMore",
                    start = offset,
                    end = offset + showMore.length
                ).firstOrNull()?.let {
                    isExpanded = !isExpanded
                }
            }
        )
    }
}

Sample:

enter image description here enter image description here

Martyry answered 4/6, 2022 at 11:39 Comment(4)
Doesn't work! I don't see any Show More or Show Less words on my endBiotin
Check your implementation - Added some pictures!, let me know so I can help/fix the instruntions.Martyry
I checked the Code Above and it is my current versionMartyry
This is exactly what i was after. This code works spot on for me. thanksPannonia
A
0

I found the posted solutions kind of overkill. Here's a simple solution:

var showMore by remember { mutableStateOf(false) }
val text =
    "Space Exploration Technologies Corp. (doing business as SpaceX) is an American aerospace manufacturer, space transportation services and communications corporation headquartered in Hawthorne, California. SpaceX was founded in 2002 by Elon Musk with the goal of reducing space transportation costs to enable the colonization of Mars. SpaceX manufactures the Falcon 9 and Falcon Heavy launch vehicles, several rocket engines, Cargo Dragon, crew spacecraft and Starlink communications satellites."

Column(modifier = Modifier.padding(20.dp)) {
    Column(modifier = Modifier
        .animateContentSize(animationSpec = tween(100))
        .clickable(
            interactionSource = remember { MutableInteractionSource() },
            indication = null
        ) { showMore = !showMore }) {

        if (showMore) {
            Text(text = text)
        } else {
            Text(text = text, maxLines = 3, overflow = TextOverflow.Ellipsis)
        }
    }
}
Anticathexis answered 19/1, 2022 at 14:58 Comment(1)
This solution does not include the "See More" text OP wants, which is the tricky partMaziar
P
0

rewritten it https://mcmap.net/q/598391/-expandable-text-in-jetpack-compose Click area only the word "... More"

@Composable
fun ExpandableText(
    modifier: Modifier = Modifier,
    textModifier: Modifier = Modifier,
    style: TextStyle = MaterialTheme.typography.body2,
    color: Color = textLightPrimary,
    text: String,
    collapsedMaxLine: Int = 6,
    showMoreText: String = "... More",
    showMoreStyle: SpanStyle = SpanStyle(
        fontWeight = FontWeight.Bold, textDecoration = TextDecoration.Underline, color = color
    ),
) {
    var isExpanded by remember { mutableStateOf(false) }
    var clickable by remember { mutableStateOf(false) }
    var lastCharIndex by remember { mutableStateOf(0) }

    val textSpanStyle = style.toSpanStyle().copy(color = color)
    Box(
        modifier = Modifier.then(modifier)
    ) {
        val annotatedString = buildAnnotatedString {
            if (clickable) {
                if (isExpanded) {
                    withStyle(style = textSpanStyle) { append(text) }
                } else {
                    val adjustText =
                        text.substring(startIndex = 0, endIndex = lastCharIndex).dropLast(showMoreText.length)
                            .dropLastWhile { Character.isWhitespace(it) || it == '.' }
                    withStyle(style = textSpanStyle) { append(adjustText) }
                    pushStringAnnotation(tag = "MORE", annotation = showMoreText)
                    withStyle(style = showMoreStyle) { append(showMoreText) }
                }
            } else {
                withStyle(style = textSpanStyle) { append(text) }
            }
        }
        ClickableText(modifier = textModifier
            .fillMaxWidth()
            .animateContentSize(),
            text = annotatedString,
            maxLines = if (isExpanded) Int.MAX_VALUE else collapsedMaxLine,
            onTextLayout = { textLayoutResult ->
                if (!isExpanded && textLayoutResult.hasVisualOverflow) {
                    clickable = true
                    lastCharIndex = textLayoutResult.getLineEnd(collapsedMaxLine - 1)
                }
            },
            style = style,
            onClick = {
                annotatedString.getStringAnnotations("MORE", it, it).firstOrNull()
                    ?.let { more -> isExpanded = !isExpanded }
            })
    }
}
Praxis answered 19/1, 2023 at 22:15 Comment(0)
U
0

Here is a simplified approach that works for me. It recompiles 2x on initialization to update the text and only once when clicked.

Model:

data class PostExpandableDescriptionModel(
var maxLine: Int,
var modifiedText: String? )

Composable:

@Composable
fun PostDescription(
    descriptionString: String,
    maxLine: Int
){
// Saves the description and triggers recomposition on change
val description = remember {
    mutableStateOf(descriptionString)
}

// Saves the data model
val model = remember {
    mutableStateOf( PostExpandableDescriptionModel(
        maxLine = maxLine,
        modifiedText = null )
    )
}
// See more text
val seeMoreText = "... See more"

Box(
    modifier = Modifier
        .padding((6.3).dp)
        .clickable {
            // Clickable box for easier user interaction
            when( model.value.maxLine ) {
                maxLine -> {
                    model.value.maxLine = Int.MAX_VALUE
                    description.value = descriptionString
                }
                else -> {
                    model.value.maxLine = maxLine
                    model.value.modifiedText?.let {
                        description.value = it
                    }
                }
            }
        }
){

    Text(
        text = description.value,
        maxLines = model.value.maxLine,
        onTextLayout = { textLayoutResult ->
            //Saves the modified text only once
            if( textLayoutResult.hasVisualOverflow && model.value.modifiedText.isNullOrEmpty()){
                val lineEndOffset = textLayoutResult.getLineEnd(maxLine - 1)
                val newString = descriptionString.substring(0, lineEndOffset - seeMoreText.length)
                model.value.modifiedText = (newString + seeMoreText).also {
                    description.value = it
                }
            }
        }
    )
}

}

Unguarded answered 25/5, 2023 at 9:35 Comment(0)
M
0
var isExpandState by remember {
                mutableStateOf(false)
            }
            val rotationState by animateFloatAsState(
                targetValue = if (isExpandState) 180f else 0f, label = ""
            )
            val constraintSet = ConstraintSet {
                val arrowId = createRefFor("arrowId")
                val textId = createRefFor("textId")
                constrain(arrowId) {
                    top.linkTo(textId.top)
                    start.linkTo(textId.end)
                    end.linkTo(textId.end)
                }
            }
            ConstraintLayout(constraintSet = constraintSet) {
                Text(
                    modifier = Modifier
                        .layoutId("textId")
                        .padding(start = 12.dp, end = 24.dp),
                    maxLines = if (!isExpandState) 1 else Int.MAX_VALUE,
                    overflow = if (!isExpandState) TextOverflow.Ellipsis else TextOverflow.Visible,
                    style = TextStyle(textAlign = TextAlign.Justify),
                    text = "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum"
                )
                Icon(
                    modifier = Modifier
                        .clickable(
                            interactionSource = remember {
                                MutableInteractionSource()
                            },
                            indication = null,
                            onClick = {
                                isExpandState = !isExpandState
                            }
                        )
                        .padding(end = 32.dp)
                        .layoutId("arrowId")
                        .rotate(rotationState),
                    imageVector = Icons.Default.ArrowDropDown,
                    contentDescription = null
                )
            }

enter image description here

Minhminho answered 20/8, 2023 at 1:57 Comment(0)
L
0
const val DEFAULT_MINIMUM_TEXT_LINE = 3

@Composable
fun ExpandableText(
    modifier: Modifier = Modifier,
    textColor: Color,
    style: TextStyle = LocalTextStyle.current,
    text: String,
    collapsedMaxLine: Int = DEFAULT_MINIMUM_TEXT_LINE,
    showMoreText: String = "Show More",
    showLessText: String = "Show Less",
    showMoreLessStyle: TextStyle = LocalTextStyle.current,
    showMoreLessColor: Color,
) {
    var isExpanded by remember { mutableStateOf(false) }
    var isShowMoreLessVisible by remember { mutableStateOf(false) }
    Column(modifier = modifier.fillMaxWidth()) {
        Text(
            modifier = Modifier
                .fillMaxWidth()
                .animateContentSize(),
            text = text,
            style = style,
            color = textColor,
            maxLines = if (isExpanded) Int.MAX_VALUE else collapsedMaxLine,
            overflow = TextOverflow.Ellipsis,
            onTextLayout = { textLayoutResult ->
                isShowMoreLessVisible = (textLayoutResult.lineCount == collapsedMaxLine && textLayoutResult.hasVisualOverflow)
                    || (textLayoutResult.lineCount > collapsedMaxLine && !textLayoutResult.hasVisualOverflow)
            }
        )
        if (isShowMoreLessVisible) {
            Spacer(Modifier.height(8.dp))
            Text(
                text = if (isExpanded) showLessText else showMoreText,
                Modifier.clickable { isExpanded = !isExpanded },
                style = showMoreLessStyle,
                color = showMoreLessColor
            )
        }
    }
}
Lipo answered 20/10, 2023 at 9:59 Comment(1)
Please add description for your code.Liszt
T
0

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)
        )
    }
}
Tirol answered 10/11, 2023 at 17:47 Comment(0)
M
-2

I have an implementation here. Like others have said, we should use onTextLayout to grab the necessary measurements like text width, etc. In my example, I tried to minimize the recomposition by remembering necessary values

Recomposition and skip count: https://user-images.githubusercontent.com/23420470/210508144-3f8de75a-bc5f-4410-b0dc-ba5c4fb399a7.mov

Malonis answered 4/1, 2023 at 8:0 Comment(2)
While this link may answer the question, it is better to include the essential parts of the answer here and provide the link for reference. Link-only answers can become invalid if the linked page changes. - From ReviewSyconium
The link no longer works, so there is no answer here anymoreKingsbury

© 2022 - 2024 — McMap. All rights reserved.