Skip to content

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-demoAndroidExpandableTextDemoActivity.kt
Flutter 展开更多(TextPainter)flutter-expandable-text-demoflutter_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,但点击区和复用边界同样要手工管。

底层机制 ​

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

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("展开更多")
    }
}

这个方案的优点是快,但它解决的是“有没有溢出”,不是“展开更多必须紧贴第三行末尾”。

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
}

配上 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;
}

5. Flutter:简单方案只是 maxLines + TextOverflow.ellipsis ​

dart
Text(
  text,
  maxLines: expanded ? null : 3,
  overflow: expanded ? TextOverflow.visible : TextOverflow.ellipsis,
)

这个方案适合“只要有省略号,不要求尾部按钮贴第三行末尾”的场景。一旦产品要求尾部点击或精确拼接,就必须回到 TextPainter。

6. 这个方案为什么更稳 ​

  • 先判断原文是否真的超过 3 行
  • 超过才进入截断
  • 用尾部文案一起参与测量,而不是事后硬拼
  • 二分查找正文边界,避免一个字一个字试
  • 展开态由父层控制,列表复用不会串项

Android / Flutter / Web / Backend 对照 ​

维度Jetpack ComposeAndroid ViewFlutteriOS / SwiftUIWeb
基础截断maxLines + TextOverflowTextView ellipsizemaxLines + TextOverflowText + lineLimitCSS line-clamp
精确测量TextMeasurer / onTextLayoutStaticLayout / LayoutTextPainterSwiftUI 原生受限,复杂场景常借 UIKit 测量canvas measure / DOM layout
展开状态rememberSaveable / 父层 hoistAdapter / ViewModel / 宿主状态StatefulWidget / 父层 state@State / @StateObject / 宿主控制组件 state / store
典型业务题第三行尾部拼“展开更多”Span + Layout 定位RichText 尾部按钮富文本摘要 + UIKit bridgeline-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离线精确测量排版结果尾部拼接文案、精确第三行截断
StaticLayoutAndroid View 文本布局测量旧 View 页面或 RecyclerView 卡片对照实现
State Hoisting谁持有展开状态列表项、可复用组件、业务统一管理
rememberSaveable / PageStorage页面局部展开态恢复单点页面交互状态需跨旋转或回栈保留

对应实验 ​

Lab说明源码
android-expandable-text-demoAndroid Compose TextMeasurer、Compose onTextLayout、Android View StaticLayout + Spannable 对照AndroidExpandableTextDemoActivity.kt
flutter-expandable-text-demoFlutter TextPainter 精确方案、简单 ellipsis 方案、父层 hoist 列表状态flutter_expandable_text_demo_page.dart

复习检查题 ​

  1. 为什么“第三行后接展开更多”不应按字符数直接截断?

    答:因为真正影响结果的是宽度、字体、行高、中英文与 emoji 等排版因素,不同字符在不同样式下占宽不同;按字符数截断无法稳定保证尾部落在第三行末尾。

  2. TextMeasurer / TextPainter 相比简单 onTextLayout 或 ellipsis 的核心优势是什么?

    答:它能让“截断后的正文 + 尾部文案”一起参与离线测量,更适合精确预留尾部按钮空间,而不是等布局完再被动修补。

  3. 为什么展开状态默认更适合做状态提升?

    答:因为它经常是列表项或业务组件的一部分,父层需要控制恢复、互斥展开、滚动复用和统一收起;放组件内部会削弱可控性和可测试性。

  4. 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)

对应 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,
      ),
    ],
  ),
);

对应 Lab:flutter-expandable-text-demo · flutter_expandable_text_demo_page.dart

站点构建时间:2026/8/24 23:43:17