Jetpack Compose : 超简单实现文本展开和收起

2,990 阅读5分钟

前言

文本展开和收起功能可以在需要显示较长文本或内容丰富的情况下,提供更好的用户体验和页面可读性,同时减少页面的冗余信息。可以说是发中常见的功能之一。

需要实现的点

  1. 文本超出最大显示行数时尾部显示"...展开"
  2. 点击"...展开"时显示全部文本并且尾部显示"...收起"

SVID_20231226_113118_1 00_00_00-00_00_30.gif

看看Text为我们提供了啥

查看Text的源码很容易看到overflow和maxLines两个属性。

@Composable
fun Text(
    // 要显示的文本内容
    text: String,
    // Modifier修饰符
    modifier: Modifier = Modifier,
    // 文本内容溢出处理方式:Clip、Ellipsis、Visible
    overflow: TextOverflow = TextOverflow.Clip,
    // 最大文本行数
    maxLines: Int = Int.MAX_VALUE,
    // 计算新文本布局时执行的回调。
    onTextLayout: (TextLayoutResult) -> Unit = {},
    // 文本的样式配置
    style: TextStyle = LocalTextStyle.current
)

当我们的overflow设置为"Ellipsis"且文本超出最大行数时显示"..."。

两种思路:覆盖 vs 替换

拿到"..."之后,怎么把"...展开"接到它后面?

思路一:覆盖(本文最初的实现)

在 Box 里叠两个 Text,用 graphicsLayer 把"...展开"挪到行尾盖住原生的"...":

Box {
    Text()
    Text(
        text = "...展开",
        modifier = Modifier
            .graphicsLayer {
                translationX = ellipsisLeft
                translationY = ellipsisBottom - size.height
            }
    )
}

能用,但代价不小:

代价说明
坐标要自己算得从 TextLayoutResult 反查行尾位置,写错了就错位/重叠
常驻第二个 Text即使没截断也要参与 measure/layout(旧实现里空串也在渲染)
多一层 graphicsLayer每个 item 一个 layer
遮挡用的背景色必须传 backgroundColor 才能盖住原生省略号
布局轮次多判断是否截断、算后缀宽度、写位置各要一轮,实测 4 轮才收敛

在信息流列表里,每个 item 都要走一遍这套流程,滚动起来就是掉帧。

思路二:替换(本文现在采用)

不叠 Text,而是把末行尾部若干字符直接换成"...展开",整个组件仍然只有 1 个 Text:

Text(
    text = buildAnnotatedString {
        append(text.subSequence(0, cutAt))   // 末行尾部截掉几个字符
        withStyle(SpanStyle(color = ellipsisColor)) { append("...展开") }
    }
)

好处很直接:坐标不用算了(后缀就是正文的一部分,排版引擎自己排)、第二个 Text 和 layer 都没了、遮挡背景也不需要了,布局轮次从 4 轮降到 2 轮。

如何确定「从哪里截断」

关键就是 onTextLayout 回调拿到的 TextLayoutResult,用到这几个方法:

class TextLayoutResult constructor(

    val layoutInput: TextLayoutInput,

    val multiParagraph: MultiParagraph,

    val size: IntSize
) {

    val lineCount: Int get() = multiParagraph.lineCount

    fun isLineEllipsized(lineIndex: Int): Boolean = multiParagraph.isLineEllipsized(lineIndex)

    /**
     *  获取指定行的结尾字符下标(visibleEnd = true 时不包含被省略的部分)
     */
    fun getLineEnd(lineIndex: Int, visibleEnd: Boolean = true): Int =
        multiParagraph.getLineEnd(lineIndex, visibleEnd)

    fun getLineStart(lineIndex: Int): Int = multiParagraph.getLineStart(lineIndex)

    /**
     *	获取指定位置字符的水平偏移量
     */
    fun getHorizontalPosition(offset: Int, usePrimaryDirection: Boolean): Float =
        multiParagraph.getHorizontalPosition(offset, usePrimaryDirection)

}

截断点的求法:从末行行尾往前退字符,直到腾出的宽度够放下后缀。

末行已经有布局结果了,所以字符宽度可以直接用 getHorizontalPosition 的差值量出来,不需要对正文做任何 measure:

val lineEnd = result.getLineEnd(lastLine, visibleEnd = true)
val lineStart = result.getLineStart(lastLine)
val endX = result.getHorizontalPosition(lineEnd, true)

var back = 0
var freed = 0f
while (lineEnd - back > lineStart && freed < suffixWidth + WIDTH_EPSILON) {
    back++
    freed = endX - result.getHorizontalPosition(lineEnd - back, true)
}
val cutAt = (lineEnd - back).coerceAtLeast(lineStart)

后缀宽度还是用 rememberTextMeasurer 量,但它只有"...展开"和"...收起"两种取值,缓存起来也就测两次:

val suffixWidth = remember(measurer, style, suffix) {
    measurer.measure(text = suffix, style = style).size.width
}

⚠️ 收敛问题:截断后文本会重新布局,若仍显示不下,会再退几个字符。 因此 cutAt 只允许变小(if (cutAt < 0 || next < cutAt) cutAt = next), 保证单调收敛,不会在两个截断位置之间来回抖动。

完整代码

private const val ELLIPSIS_MORE = "...展开"
private const val ELLIPSIS_COLLAPSE = "...收起"

/** 末行腾出后缀宽度时的安全余量,避免「刚好差一点」导致再多一轮布局。 */
private const val WIDTH_EPSILON = 1f

@Composable
fun EllipsisText(
    text: AnnotatedString,
    color: Color,
    backgroundColor: Color,
    fontSize: TextUnit = TextUnit.Unspecified,
    lineHeight: TextUnit = TextUnit.Unspecified,
    maxLines: Int = Int.MAX_VALUE,
    inlineContent: Map<String, InlineTextContent> = mapOf(),
    onTextLayout: (TextLayoutResult) -> Unit = {},
    ellipsisColor: Color = AppColors.blue,
    onClick: () -> Unit = {},
    animate: Boolean = true,
) {
    val style = remember(color, fontSize) {
        TextStyle.Default.copy(color = color, fontSize = fontSize)
    }
    val measurer = rememberTextMeasurer()

    var expand by remember { mutableStateOf(false) }
    // 源文本/行数/样式变化时重置为「未截断」,重新走一轮判定
    var cutAt by remember(text, maxLines, style) { mutableIntStateOf(-1) }

    val suffix = if (expand) ELLIPSIS_COLLAPSE else ELLIPSIS_MORE
    // 后缀只有两种取值,测一次即缓存
    val suffixWidth = remember(measurer, style, suffix) {
        measurer.measure(text = suffix, style = style).size.width
    }

    val displayText: AnnotatedString = remember(text, cutAt, expand, suffix, ellipsisColor) {
        buildAnnotatedString {
            when {
                expand -> {
                    append(text)
                    withStyle(SpanStyle(color = ellipsisColor)) { append(suffix) }
                }
                cutAt < 0 -> append(text)
                else -> {
                    append(text.subSequence(0, cutAt))
                    withStyle(SpanStyle(color = ellipsisColor)) { append(suffix) }
                }
            }
        }
    }

    Box(modifier = if (animate) Modifier.animateContentSize() else Modifier) {
        Text(
            text = displayText,
            modifier = Modifier
                .clickable(
                    onClick = {
                        onClick()
                        // 未截断时没有后缀可点,点击也不该切换展开态
                        if (expand || cutAt >= 0) expand = !expand
                    },
                    indication = null,
                    interactionSource = remember { MutableInteractionSource() }
                )
                .background(backgroundColor),
            lineHeight = lineHeight,
            overflow = TextOverflow.Ellipsis,
            maxLines = if (expand) Int.MAX_VALUE else maxLines,
            inlineContent = inlineContent,
            onTextLayout = { result ->
                onTextLayout(result)
                if (expand || maxLines == Int.MAX_VALUE) return@Text

                val lastLine = result.lineCount - 1
                // 未截断:保持现状(cutAt 若为 -1 就一直是 -1;若已截断说明上一轮截得正好)
                if (!result.isLineEllipsized(lastLine)) return@Text

                val lineEnd = result.getLineEnd(lastLine, visibleEnd = true)
                val lineStart = result.getLineStart(lastLine)
                val endX = result.getHorizontalPosition(lineEnd, true)
                // 从行尾往前退,直到腾出 ≥ 后缀宽度的空间
                var back = 0
                var freed = 0f
                while (lineEnd - back > lineStart && freed < suffixWidth + WIDTH_EPSILON) {
                    back++
                    freed = endX - result.getHorizontalPosition(lineEnd - back, true)
                }
                val next = (lineEnd - back).coerceAtLeast(lineStart)
                // 只允许变小:保证单调收敛,不会在两种截断位置间来回抖动
                if (cutAt < 0 || next < cutAt) cutAt = next
            },
            style = style
        )
    }
}

如何使用

普通文本:

EllipsisText(
    text = buildAnnotatedString {
        append(
            "I am happy to join with you today in what will go down in history as the greatest demonstration for freedom in the history of our nation."
        )
    },
    color = MaterialTheme.colorScheme.onPrimary,
    backgroundColor = MaterialTheme.colorScheme.background,
    fontSize = 14.sp,
    maxLines = 1,
)

带 inlineContent(图文混排一样能截断):

EllipsisText(
    text = buildAnnotatedString {
        appendInlineContent("icon")
        append("壬戌之秋,七月既望,苏子与客泛舟游于赤壁之下……")
    },
    color = MaterialTheme.colorScheme.onPrimary,
    backgroundColor = MaterialTheme.colorScheme.background,
    fontSize = 14.sp,
    maxLines = 1,
    inlineContent = mapOf(
        "icon" to InlineTextContent(
            Placeholder(14.sp, 14.sp, PlaceholderVerticalAlign.Center)
        ) { Image(...) }
    ),
)

在信息流卡片里(core:ui 的 FeedCard):

// 缓存 AnnotatedString:buildAnnotatedString 会在每次重组都重新分配一次
val descText = remember(data.desc) { AnnotatedString(data.desc) }
EllipsisText(
    text = descText,
    color = MaterialTheme.colorScheme.onSecondary,
    backgroundColor = MaterialTheme.colorScheme.surfaceContainer,
    fontSize = 14.sp,
    maxLines = 3,
    // 列表里展开动画是纯开销:滚动时每个 item 都多一层动画层
    animate = false,
)

在列表里用的三个注意点

  1. animate = false:animateContentSize() 会给每个 item 加一层动画开销,信息流里没必要。
  2. 缓存 AnnotatedString:buildAnnotatedString { append(desc) } 每次重组都会重新分配,用 remember(desc) { AnnotatedString(desc) } 包一下。
  3. UI 状态标 @Immutable:让 Compose 编译器信任其稳定性,避免父级重组无谓地传导到卡片(本项目 FeedCardUIState 就是这么做的)。

Thanks

以上就是本篇文章的全部内容,如有问题欢迎指出,我们一起进步。
如果觉得本篇文章对您有帮助的话请点个赞让更多人看到吧,您的鼓励是我前进的动力。
谢谢~~

源代码地址