各位 Android 开发者,你们有没有实现过这样一个组件?


即在文本组件上实现 "显示全文/收起" 功能。
这种可展开文本组件提供了对截断文本的访问功能,可以切换完整文本的可见性,让用户能够展开或收起内容中的文本,使得文本组件可以适应不同的使用场景。
好,现在我们就用 Compose Text 实现这个功能。
上代码
Kotlin
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
const val DEFAULT_MINIMUM_TEXT_LINE = 3
/**
* An text component that provides access to truncated text with a dynamic ... Show More/Show Less button.
*/
@Composable
fun TruncateText(
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,
fontSize: TextUnit
) {
// State variables to track the expanded state, clickable state, and last character index.
var isExpanded by remember { mutableStateOf(false) }
var clickable by remember { mutableStateOf(false) }
var lastCharIndex by remember { mutableIntStateOf(0) }
// Box composable containing the Text composable.
Box(
modifier = Modifier
.clickable(clickable) {
isExpanded = !isExpanded
}
.then(modifier)
) {
// Text composable with buildAnnotatedString to handle "Show More" and "Show Less" buttons.
androidx.compose.material3.Text(
modifier = textModifier
.fillMaxWidth()
.animateContentSize(),
text = buildAnnotatedString {
if (clickable) {
if (isExpanded) {
// Display the full text and "Show Less" button when expanded.
append(text)
withStyle(style = showLessStyle) { append(showLessText) }
} else {
// Display truncated text and "Show More" button when collapsed.
val adjustText = text.substring(startIndex = 0, endIndex = lastCharIndex)
.dropLast(showMoreText.length)
.dropLastWhile { Character.isWhitespace(it) || it == '.' }
append(adjustText)
withStyle(style = showMoreStyle) { append(showMoreText) }
}
} else {
// Display the full text when not clickable.
append(text)
}
},
// Set max lines based on the expanded state.
maxLines = if (isExpanded) Int.MAX_VALUE else collapsedMaxLine,
fontStyle = fontStyle,
// Callback to determine visual overflow and enable click ability.
onTextLayout = { textLayoutResult ->
if (!isExpanded && textLayoutResult.hasVisualOverflow) {
clickable = true
lastCharIndex = textLayoutResult.getLineEnd(collapsedMaxLine - 1)
}
},
style = style,
textAlign = textAlign,
fontSize = fontSize
)
}
}
这次比较直接,先上代码。
默认情况下,此组件配置为显示最少 3 行,如 collapsedMaxLine 参数所指定,确保显示"展开更多"按钮。
你可以通过调整 collapsedMaxLine 参数来自定义此行为,也可以使用 showMoreText 和 showLessText 参数分别修改"展开更多"和"收起"的文本。
先看效果
使用时,只需在你需要的地方调用该组件,以下是示例代码。
Kotlin
Box {
TruncateText(
text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
fontSize = 16.sp
)
}
效果如下:

当前实现,你可以通过点击文本组件的任意位置来展开或收起文本。
怎么实现

我们不仅要展示很多,还要学得更多。
要搞懂 TruncateText 这个组件的实现,我们主要搞懂 onTextLayout 回调即可。
这是实现整个组件最核心、最精髓的一步。
当 Text 组件在给定的约束条件(Constraints)下,计算出文本应该如何换行、每行多宽、总共占用多大尺寸后,就会立刻回调 onTextLayout,并传入一个 TextLayoutResult 对象。这个过程发生在布局阶段的测量步骤,早于元素被放置(Placement)和绘制(Drawing)到屏幕上。
因此我们在该回调中获取文本状态并进行重新调整时,由于此时还未进行绘制,先前的文本状态不会在页面上发生闪烁。
现在,我们通过这个回调解决了两个问题:
- 到底有没有超出 3 行? :我们利用
textLayoutResult.hasVisualOverflow属性。当文本内容因为被maxLines限制而没有完全展示出来时,这个属性就会是true。通过它,我们就能判断这段文本是不是真的够长。如果没超出,就老老实实当普通文字展示;如果超出了,我们才把clickable设置为true,允许用户点击它。 - 省略号和展开按钮该插在哪里? :如果我们要把文本截断,怎么知道要截取到哪个字?这就是
textLayoutResult.getLineEnd(lineIndex)发挥作用的地方了。它可以获取指定行末尾字符在原始字符串中的精确索引。因为行号是从0开始的,我们传入collapsedMaxLine - 1,就能拿到最后一行(比如第3行)最后一个可见字符的位置,并保存到lastCharIndex中。
有了这个精准的 lastCharIndex,后续我们才知道该去哪里"动刀",截取原文并接上"... Show More"。
注意避坑
因为 onTextLayout 是在布局(Layout)阶段触发的,如果你在这个回调里直接修改了某个被 Text 读取的 State(例如你想根据文本是否溢出来动态调整字体大小),会触发以下情况:
- 向后状态写入:你在布局阶段修改了状态,而这个状态又会触发重组(Composition 阶段),这会导致 Compose 在当前帧或下一帧强制重新执行重组。
- 无限循环风险:如果处理不当(比如字号变小 -> 布局改变 -> 发现不溢出了 -> 字号变大 -> 布局改变 -> 发现又溢出了),极易引起无限循环重组。
正确的做法是:仔细控制状态更新的终止条件。一定要定义好什么情况下停止更新状态。
一点想法
在本文的 TruncateText 实现中,我们还提供了一些参数供大家自定义组件外观,这里简单过一下:
modifier/textModifier:分别用于控制外层容器和内层文本的布局表现。showMoreText/showLessText及对应的Style:让你能够自由修改"展开/收起"的文案与排版样式。collapsedMaxLine:用来定义收起状态下保留的最大行数。- 其他如
style、fontSize、textAlign等:均透传给底层的Text组件,用于常规文字设置。
这只是一个基础示例,大家完全可以按照自己的业务要求去定制和扩展这个功能(比如改变点击触控区域、增加更丰富的过渡动画等)。万变不离其宗,只要掌握了核心的 onTextLayout 回调机制,利用 TextLayoutResult 获取到精准的文本布局数据,在 Jetpack Compose 中处理这类复杂的文本交互就会变得游刃有余。
唯一需要注意的地方正如前面所提醒的,在布局阶段修改状态始终是一把双刃剑,使用 onTextLayout 时务必严格控制状态更新的边界,避免引起无限重组等性能隐患。