Appearance
Jetpack Compose 文本测量、自定义截断与“展开更多”
回到总览:状态管理、路由、架构
相关模块:Jetpack Compose 状态模型、StateFlow 与重组优化 · Compose / Flutter / Vue 状态管理对照
一句话定义
当产品需求不满足 TextOverflow.Ellipsis 这类固定尾部策略时,Jetpack Compose 需要借助 TextMeasurer、onTextLayout 与状态提升(State Hoisting)来实现“第三行后拼展开更多”“按宽度精确截断”“点击尾部展开/收起”等自定义文本呈现;同一题在 Android View 里通常落到 StaticLayout / Spannable,在 Flutter 里则对应 TextPainter、RichText 与父层状态管理。
代码索引
| 主题 | Lab 说明 | 源码 |
|---|---|---|
| Android 展开更多(Compose + View) | android-expandable-text-demo | AndroidExpandableTextDemoActivity.kt |
| Flutter 展开更多(TextPainter) | flutter-expandable-text-demo | flutter_expandable_text_demo_page.dart |
为什么需要
- 为什么
maxLines = 3+overflow = Ellipsis往往不够?- 一句话答:因为它只能给你系统固定省略号,无法保证“展开更多”正好接在第三行末尾,也无法按业务要求控制尾部文案和点击区。
- 为什么“第三行后追加展开更多”本质上不是简单截字符串?
- 一句话答:因为真正约束条件是“给定宽度 + 给定字体样式下的排版结果”,不是字符数;中英文混排、emoji、字体差异都会让按字数截断失真。
- 这个问题和状态管理有什么关系?
- 一句话答:文本是否展开属于低频 UI 状态,应该提升到父层或页面状态;文本测量结果则应按输入缓存,避免每次重组重复计算。
- 什么时候该用
TextMeasurer,什么时候onTextLayout就够?- 一句话答:只需知道是否溢出、第三行末尾位置时可先用
onTextLayout;要精确为“尾部附加文案”预留宽度时,TextMeasurer更稳。
- 一句话答:只需知道是否溢出、第三行末尾位置时可先用
- 为什么 Android View 和 Flutter 也不能只靠内置 ellipsis 凑合?
- 一句话答:因为“展开更多”往往要带点击、带精确尾部拼接、带列表复用状态,这已经超出默认 ellipsis 的能力边界。
这页解决什么问题
- “如果不足三行不显示展开更多,超过三行才显示”怎么做?
- 一句话答:先测原文是否超过 3 行;未超过直接展示全文,超过再进入“为尾部文案预留宽度后的裁切”逻辑。
- 展开状态该放在 Text 组件内部还是父层?
- 一句话答:默认应放父层,让文本组件尽量无状态;列表项场景尤其不能让低层组件偷偷持有业务展开态。
- 如何避免“每次滚动/重组都重新测量一遍文本”?
- 一句话答:把测量依赖收敛到
text + width + style + collapsedMaxLines + suffix,并用remember(...)或等价缓存结果。
- 一句话答:把测量依赖收敛到
- Android View、Compose、Flutter 在这道题上的实现差异到底在哪里?
- 一句话答:Compose / Flutter 都偏声明式,需要你自己把“排版测量”和“展开态”拆开;Android View 更容易走
StaticLayout + Spannable,但点击区和复用边界同样要手工管。
- 一句话答:Compose / Flutter 都偏声明式,需要你自己把“排版测量”和“展开态”拆开;Android View 更容易走
底层机制
1. 这个需求本质是“排版测量”问题,不是“字符串截断”问题
图注补充:真正决定截断点的不是“第几个字符”,而是“这个字符在当前排版约束下是否还放得下”。
对应 Lab:android-expandable-text-demo · AndroidExpandableTextDemoActivity.kt · flutter-expandable-text-demo · flutter_expandable_text_demo_page.dart
2. 三条常见实现路径
| 路径 | 核心手段 | 适合场景 | 风险 |
|---|---|---|---|
Compose onTextLayout 路径 | 先让 Text 排一次,再读 lineCount / getLineEnd() | 只判断溢出、简单尾部处理 | 尾部额外文案可能把第三行再次挤爆 |
| Compose / Flutter 精确测量路径 | TextMeasurer / TextPainter 离线测量,再决定裁切边界 | 精确尾部拼接、自定义截断策略 | 代码更长,需要认真做缓存 |
Android View StaticLayout + Spannable 路径 | 先离线测量,再把尾部点击文案做成 span | 旧页面、RecyclerView 卡片、富文本摘要 | 点击区、复用和样式状态更容易乱 |
3. 和状态提升的连接点
这类组件最容易犯的错就是:
- 在内部
remember一个expanded - 列表复用后状态串项
- 父层无法统一折叠/展开策略
所以默认建议:
- 组件尽量 stateless
expanded从父层传入- 点击事件向上抛
代码示例与方案对比
1. Compose:最推荐的工程化思路是 TextMeasurer + 状态提升
对应 Lab:android-expandable-text-demo · AndroidExpandableTextDemoActivity.kt
kotlin
@Composable
fun ExpandableText(
text: String,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
collapsedMaxLines: Int = 3,
suffix: String = "… 展开更多",
style: TextStyle = LocalTextStyle.current,
) {
val textMeasurer = rememberTextMeasurer()
var widthPx by remember { mutableIntStateOf(0) }
val collapsedText = remember(text, widthPx, style, collapsedMaxLines, suffix) {
if (widthPx == 0) return@remember null
val full = textMeasurer.measure(
text = AnnotatedString(text),
style = style,
constraints = Constraints(maxWidth = widthPx),
maxLines = collapsedMaxLines + 1,
)
if (full.lineCount <= collapsedMaxLines) return@remember text
var low = 0
var high = text.length
var best = ""
while (low <= high) {
val mid = (low + high) / 2
val candidate = text.take(mid).trimEnd() + suffix
val result = textMeasurer.measure(
text = AnnotatedString(candidate),
style = style,
constraints = Constraints(maxWidth = widthPx),
maxLines = collapsedMaxLines,
)
if (result.hasVisualOverflow) {
high = mid - 1
} else {
best = candidate
low = mid + 1
}
}
best
}
Box(
modifier = modifier.onSizeChanged { widthPx = it.width }
) {
val displayText = when {
expanded -> text
collapsedText == null -> text
else -> collapsedText
}
Text(text = displayText, style = style)
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
2. Compose:更快但边界更脆的方案是 onTextLayout
kotlin
@Composable
fun SimpleExpandableText(text: String) {
var expanded by rememberSaveable { mutableStateOf(false) }
var overflow by remember { mutableStateOf(false) }
Text(
text = text,
maxLines = if (expanded) Int.MAX_VALUE else 3,
overflow = TextOverflow.Ellipsis,
onTextLayout = { result ->
overflow = result.hasVisualOverflow
}
)
if (!expanded && overflow) {
Text("展开更多")
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这个方案的优点是快,但它解决的是“有没有溢出”,不是“展开更多必须紧贴第三行末尾”。
3. Android View:StaticLayout + Spannable 的最小思路
对应 Lab:android-expandable-text-demo · AndroidExpandableTextDemoActivity.kt
kotlin
val layout = StaticLayout.Builder
.obtain(candidate, 0, candidate.length, textPaint, widthPx)
.setMaxLines(3)
.setEllipsize(null)
.build()
if (layout.lineCount <= 3 && layout.getLineEnd(2) == candidate.length) {
best = candidate
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
配上 SpannableString 或 ClickableSpan,就能把尾部“展开更多”做成旧 View 页面里的可点击区。问题不在能不能做,而在状态、样式和 RecyclerView 复用都要你自己兜住。
4. Flutter:TextPainter 精确测量方案
对应 Lab:flutter-expandable-text-demo · flutter_expandable_text_demo_page.dart
dart
final painter = TextPainter(
text: TextSpan(text: candidate, style: style),
maxLines: collapsedMaxLines,
textDirection: TextDirection.ltr,
)..layout(maxWidth: maxWidth);
if (!painter.didExceedMaxLines) {
best = candidate;
low = mid + 1;
} else {
high = mid - 1;
}1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
5. Flutter:简单方案只是 maxLines + TextOverflow.ellipsis
dart
Text(
text,
maxLines: expanded ? null : 3,
overflow: expanded ? TextOverflow.visible : TextOverflow.ellipsis,
)1
2
3
4
5
2
3
4
5
这个方案适合“只要有省略号,不要求尾部按钮贴第三行末尾”的场景。一旦产品要求尾部点击或精确拼接,就必须回到 TextPainter。
6. 这个方案为什么更稳
- 先判断原文是否真的超过 3 行
- 超过才进入截断
- 用尾部文案一起参与测量,而不是事后硬拼
- 二分查找正文边界,避免一个字一个字试
- 展开态由父层控制,列表复用不会串项
Android / Flutter / Web / Backend 对照
| 维度 | Jetpack Compose | Android View | Flutter | iOS / SwiftUI | Web |
|---|---|---|---|---|---|
| 基础截断 | maxLines + TextOverflow | TextView ellipsize | maxLines + TextOverflow | Text + lineLimit | CSS line-clamp |
| 精确测量 | TextMeasurer / onTextLayout | StaticLayout / Layout | TextPainter | SwiftUI 原生受限,复杂场景常借 UIKit 测量 | canvas measure / DOM layout |
| 展开状态 | rememberSaveable / 父层 hoist | Adapter / ViewModel / 宿主状态 | StatefulWidget / 父层 state | @State / @StateObject / 宿主控制 | 组件 state / store |
| 典型业务题 | 第三行尾部拼“展开更多” | Span + Layout 定位 | RichText 尾部按钮 | 富文本摘要 + UIKit bridge | line-clamp + JS 展开 |
常见场景
1. 信息流卡片简介
对应 Lab:android-expandable-text-demo · flutter-expandable-text-demo
需求往往是:
- 最多显示 3 行
- 超过显示“展开更多”
- 不超过不显示
- 列表滚动回来时保持展开状态
真正要决定的是:
- 展开状态按 itemId 存哪
- 是否允许多个 item 同时展开
- 回收复用后是否要恢复
2. 评论区 / 问答区富文本摘要
这里比普通纯文本更麻烦:
- 富文本 span 会影响测量
- emoji / 链接 / 特殊字体会影响宽度
- 部分文本可点击时,尾部“展开更多”可能也要成为 span
3. 详情页正文展开/收起
如果需求是“只在详情页单处使用”,内部 rememberSaveable 可以接受; 如果在列表、可复用卡片、业务组件库中复用,优先状态提升。
常见误配、事故后果与排障
1. 事故:按字数截断后再硬拼“展开更多”
- 误配原因:把文本布局问题当纯字符串处理。
- 后果:不同机型宽度、字体缩放、中英文混排下尾部换行或断裂。
- 排障与修法:让尾部文案参与测量,基于实际布局宽度求截断点。
2. 事故:展开状态写死在组件内部
- 误配原因:图快,直接
rememberSaveable { mutableStateOf(false) }或局部setState。 - 后果:列表复用时状态难控,父层无法统一收起/展开。
- 排障与修法:默认做成 stateless 组件,由父层按 itemId 持有
expanded。
3. 事故:每次重组都重新测量全文
- 误配原因:把
textMeasurer.measure()或TextPainter.layout()直接写在函数体里反复执行。 - 后果:长文本列表场景掉帧、滚动卡顿。
- 排障与修法:按
text + width + style + suffix + maxLines记忆化缓存测量结果。
4. 事故:Android View / Flutter 里以为“系统 ellipsis + 额外 Text 按钮”就等于完整方案
- 误配原因:把“显示一个按钮”与“尾部拼接正确”混为一谈。
- 后果:展开按钮独占下一行、排版跳变、点击热区错位。
- 排障与修法:如果产品要“尾部贴合第三行末尾”,必须让尾部文本一起参与测量。
与相近概念对比
| 概念 | 解决什么问题 | 什么时候优先用 |
|---|---|---|
TextOverflow.Ellipsis | 默认省略号截断 | 只是简单裁切,不需要尾部交互 |
onTextLayout | 判断是否溢出、取行尾信息 | 轻量判断是否显示“展开”按钮 |
TextMeasurer / TextPainter | 离线精确测量排版结果 | 尾部拼接文案、精确第三行截断 |
StaticLayout | Android View 文本布局测量 | 旧 View 页面或 RecyclerView 卡片对照实现 |
| State Hoisting | 谁持有展开状态 | 列表项、可复用组件、业务统一管理 |
rememberSaveable / PageStorage | 页面局部展开态恢复 | 单点页面交互状态需跨旋转或回栈保留 |
对应实验
| Lab | 说明 | 源码 |
|---|---|---|
| android-expandable-text-demo | Android Compose TextMeasurer、Compose onTextLayout、Android View StaticLayout + Spannable 对照 | AndroidExpandableTextDemoActivity.kt |
| flutter-expandable-text-demo | Flutter TextPainter 精确方案、简单 ellipsis 方案、父层 hoist 列表状态 | flutter_expandable_text_demo_page.dart |
复习检查题
为什么“第三行后接展开更多”不应按字符数直接截断?
答:因为真正影响结果的是宽度、字体、行高、中英文与 emoji 等排版因素,不同字符在不同样式下占宽不同;按字符数截断无法稳定保证尾部落在第三行末尾。
TextMeasurer/TextPainter相比简单onTextLayout或ellipsis的核心优势是什么?答:它能让“截断后的正文 + 尾部文案”一起参与离线测量,更适合精确预留尾部按钮空间,而不是等布局完再被动修补。
为什么展开状态默认更适合做状态提升?
答:因为它经常是列表项或业务组件的一部分,父层需要控制恢复、互斥展开、滚动复用和统一收起;放组件内部会削弱可控性和可测试性。
Android View 和 Flutter 为什么也很容易在这题上踩坑?
答:因为它们同样要同时处理排版测量、尾部点击区和列表复用状态;只靠默认 ellipsis 无法满足真实业务的精确尾部拼接要求。
速记
- 这题是排版测量题,不是字符串截断题
- 只判断溢出可先用
onTextLayout - 精确第三行尾部拼接优先
TextMeasurer/TextPainter - Android View 对照实现通常是
StaticLayout + Spannable - 展开状态默认 hoist,列表项尤其如此
- 测量结果要缓存,别在重组里反复算
Android View 复杂文本边界
View Tab 除主段落外,还列出四类常见「复杂文本」边界,用来解释为什么 StaticLayout + ClickableSpan 在这些场景比默认 ellipsis 更容易出问题:
| 边界 | 典型表现 | 排障要点 |
|---|---|---|
| 中英文混排 | 按字符数截断会错位 | 宽度按字形变化,suffix 必须参与 StaticLayout 测量 |
| emoji 混排 | 尾部 suffix 掉到第四行 | emoji 占宽不等于一个 char,不能靠 length 估算 |
| 超长单词 | 单词撑破行宽 | 二分裁正文时要把 suffix 一起测,避免 suffix 被挤下去 |
| 正文 link span + 尾部 suffix | 点击区冲突或测量不准 | URLSpan 与尾部 ClickableSpan 分开设置;link 正文用 URLSpan,suffix 用独立 span |
kotlin
// link 正文:URLSpan;尾部 suffix:ClickableSpan —— 测量与点击区分开
val spannable = SpannableString(collapsedText)
spannable.setSpan(URLSpan(url), linkStart, linkEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
spannable.setSpan(expandClickableSpan, suffixStart, suffixEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)1
2
3
4
2
3
4
对应 Lab:android-expandable-text-demo · View Tab 四类样例见 AndroidExpandableTextDemoActivity.kt
Flutter:RichText 收起尾部与父层列表展开策略
展开态不应只展示纯 Text 全文——产品通常还要求尾部可点的「收起」。Flutter 里与收起态「… 展开更多」对称的做法是:展开后用 RichText 拼正文 + 尾部 收起,并为尾部单独挂 TapGestureRecognizer(或 GestureDetector),在 StatefulWidget.dispose 里释放 recognizer,避免泄漏。
父层列表还要决定「多项可同时展开」还是「单项互斥展开」:
| 策略 | 父层状态 | 点击 item 时 | 典型场景 |
|---|---|---|---|
| 多项可同时展开 | Set<int> 或 Map<id, bool> | toggle 当前 id,不影响其它项 | 信息流简介、FAQ 列表 |
| 单项互斥展开 | 同上,但写入前先 clear() 再 add(id) | 新项展开时收起其它项 | 手风琴摘要、强调「当前阅读项」 |
| 全部展开 / 全部收起 | 父层批量 addAll / clear() | 不经过单个 item 的 toggle | 管理端预览、批量阅读模式 |
收起态仍应走 TextPainter 二分测量,让 … 展开更多 参与排版;展开态全文不必再裁切,但尾部 收起 应走与展开按钮一致的链接样式,便于用户建立「点尾部切换态」的肌肉记忆。
dart
// 父层:单项互斥 vs 多项 toggle
void toggleItem(int index) {
if (singleMode) {
expandedIds
..clear()
..add(index);
} else {
expandedIds.contains(index)
? expandedIds.remove(index)
: expandedIds.add(index);
}
}
// 子组件:展开态 RichText 尾部「收起」
RichText(
text: TextSpan(
children: [
TextSpan(text: text),
TextSpan(
text: ' 收起',
style: linkStyle,
recognizer: collapseRecognizer,
),
],
),
);1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
对应 Lab:flutter-expandable-text-demo · flutter_expandable_text_demo_page.dart