前言
文本展开和收起功能可以在需要显示较长文本或内容丰富的情况下,提供更好的用户体验和页面可读性,同时减少页面的冗余信息。可以说是发中常见的功能之一。
需要实现的点
- 文本超出最大显示行数时尾部显示"...展开"
- 点击"...展开"时显示全部文本并且尾部显示"...收起"
看看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,
)
在列表里用的三个注意点
animate = false:animateContentSize()会给每个 item 加一层动画开销,信息流里没必要。- 缓存
AnnotatedString:buildAnnotatedString { append(desc) }每次重组都会重新分配,用remember(desc) { AnnotatedString(desc) }包一下。 - UI 状态标
@Immutable:让 Compose 编译器信任其稳定性,避免父级重组无谓地传导到卡片(本项目FeedCardUIState就是这么做的)。
Thanks
以上就是本篇文章的全部内容,如有问题欢迎指出,我们一起进步。
如果觉得本篇文章对您有帮助的话请点个赞让更多人看到吧,您的鼓励是我前进的动力。
谢谢~~