Flutter 文本最多两行并自动缩小字号的实现

总结 Flutter 中基于 TextPainter 实现最多两行、超出后自动缩小字号且不省略文案的组件方案。

Flutter&DartFlutterDartTextPainter自适应文本

在 Flutter 里做多语言 UI 时,经常会遇到一个看似很小、但实际很容易踩坑的文本需求:

文案最多显示两行;如果默认字号放不下,就缩小字号;但不能把两行压成一行,也不能用省略号截断内容。

这个需求不能直接用 FittedBox 解决。FittedBox 的思路是把子组件整体缩放到父容器内,它很容易把一段本来应该换行的文本压成一行显示。对于标题、活动弹窗、订阅弹窗这类 UI,结果通常就是:文字虽然没有溢出,但排版语义变了。

更合适的方案是:保留 Text 自己的换行能力,只在文本超过最大行数时,动态计算一个合适的字号。

实现思路

核心流程分三步:

  1. 使用 LayoutBuilder 获取父容器传下来的真实宽度。
  2. 使用 TextPainter 在这个宽度内预排版文本,判断当前字号是否会超过 maxLines
  3. 如果默认字号放不下,就用二分查找找到“能完整放进 maxLines 的最大字号”。

这里的关键点是 TextPainter。它可以在不真正绘制文本的情况下完成一次布局测量。我们只需要给它同样的 TextSpanTextStyletextDirectionmaxLinesmaxWidth,然后调用 layout,再通过 didExceedMaxLines 判断是否超过最大行数。

为什么不用 FittedBox

比如下面这种写法:

FittedBox(
  child: Text(
    'New User Exclusive Deal',
    textAlign: TextAlign.center,
    style: TextStyle(fontSize: 20.sp),
  ),
)

它的问题是:FittedBox 看到的是一个已经布局好的子组件,然后把这个子组件整体缩放。文本是否换行,并不是由 FittedBox 精细控制的。很多情况下,它会让文本保持一行,然后整体缩小,最终得到“字号变小的一行文本”。

而需求其实是:

  • 默认字号能放下时,按正常字号显示。
  • 默认字号放不下时,优先保持两行排版。
  • 只有超过两行时,才缩小字号。
  • 无论如何都不使用 ellipsis 省略内容。

所以我们需要控制的是 Text 的字号,而不是整体缩放整个文本组件。

组件源码

下面是一个局部封装后的 _AutoShrinkText。它适合用于固定宽度内的标题、按钮文案、活动弹窗文案等场景。

class _AutoShrinkText extends StatelessWidget {
  // 用于替代 FittedBox:保留文本自己的换行能力,只在超过最大行数时缩小字号。
  const _AutoShrinkText(this.text, {required this.style, required this.maxLines, this.textAlign});

  final String text;
  final TextStyle style;
  final int maxLines;
  final TextAlign? textAlign;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(builder: (context, constraints) {
      // LayoutBuilder 可以拿到外层固定宽度传下来的真实宽度,用这个宽度测量文本是否会超过最大行数。
      final maxWidth = constraints.maxWidth;
      final fontSize = maxWidth.isFinite && maxWidth > 0 ? _resolveFontSize(context, maxWidth) : style.fontSize;

      return Text(
        text,
        textAlign: textAlign,
        maxLines: maxLines,
        // 字号已经按最大行数内完整显示计算过,这里不能使用 ellipsis,否则长文案仍会被省略。
        overflow: TextOverflow.visible,
        style: style.copyWith(fontSize: fontSize),
      );
    });
  }

  double? _resolveFontSize(BuildContext context, double maxWidth) {
    final defaultFontSize = DefaultTextStyle.of(context).style.fontSize ?? 14;
    final maxFontSize = style.fontSize ?? defaultFontSize;

    if (_fits(context, maxWidth, maxFontSize)) {
      return maxFontSize;
    }

    // 如果默认字号会超过 maxLines,就二分查找“能完整放进 maxLines 的最大字号”。
    // 这里先不断降低下界,保证 low 一定是可用字号;避免极长文案在 1.0 字号下仍然放不下。
    var low = 1.0;
    while (!_fits(context, maxWidth, low) && low > 0.1) {
      low /= 2;
    }

    var high = maxFontSize;

    // 12 次已经能把字号精度收敛到很小的范围,不需要找到绝对精确值。
    for (var i = 0; i < 12; i++) {
      final mid = (low + high) / 2;
      if (_fits(context, maxWidth, mid)) {
        low = mid;
      } else {
        high = mid;
      }
    }

    return low;
  }

  bool _fits(BuildContext context, double maxWidth, double fontSize) {
    // TextPainter 只做布局测量,不真正绘制;didExceedMaxLines 为 false 代表文案没有被截断。
    final painter = TextPainter(
      text: TextSpan(text: text, style: style.copyWith(fontSize: fontSize)),
      textAlign: textAlign ?? TextAlign.start,
      textDirection: Directionality.of(context),
      maxLines: maxLines,
      textScaler: MediaQuery.textScalerOf(context),
    )..layout(maxWidth: maxWidth);

    return !painter.didExceedMaxLines;
  }
}

使用时只需要给它一个固定宽度,并声明最多几行:

_AutoShrinkText(
  'New User Exclusive Deal'.tr,
  textAlign: TextAlign.center,
  maxLines: 2,
  style: TextStyle(
    fontSize: 20.sp,
    fontWeight: FontWeight.w700,
    height: 1,
    fontStyle: FontStyle.italic,
  ),
).width(160.w)

二分查找为什么够用

这里二分查找 12 次不是“尝试 12 个字号,找不到就失败”,而是在一个字号区间内不断逼近最大可用值。

假设字号范围是 1.0 ~ 20.0,二分 12 次后,误差已经很小,UI 上基本看不出差异。我们不需要找到数学意义上的绝对精确值,只需要找到一个视觉上足够接近的最大可用字号。

另外,代码里还有一个兜底逻辑:

var low = 1.0;
while (!_fits(context, maxWidth, low) && low > 0.1) {
  low /= 2;
}

它的作用是保证二分查找的下界 low 尽可能是一个可用字号。否则,如果某些极端长文案连 1.0 字号都放不进两行,直接在 1.0 ~ maxFontSize 之间二分会有边界问题。

当然,如果文案长到 0.1 字号附近仍然放不进两行,那从产品和视觉上已经不可读了。这种情况通常应该回到文案设计或布局设计上处理,而不是继续无限缩小字号。

小结

这个方案的重点是把“缩放组件”改成“计算字号”:

  • LayoutBuilder 负责拿到真实布局宽度。
  • TextPainter 负责模拟文本排版。
  • didExceedMaxLines 负责判断是否超过最大行数。
  • 二分查找负责找到最大可用字号。
  • TextOverflow.visible 避免长文案被省略。

这样既能保留 Flutter Text 的自然换行能力,又能在多语言文案变长时尽量保持完整展示。