客户端开发

TextPainter 测量够宽,Text 为什么仍然被裁切?

定位自定义 Tab 单行标题在真机被截断的原因:TextPainter 不会自动继承组件树的 DefaultTextStyle 与字体缩放,测量与绘制必须共用最终样式。

约 12 分钟读完
目录
  1. 1. Text 与 TextPainter 的样式继承差异
  2. 2. 修复前后代码对照:共用同一份最终排版上下文
  3. 3. 使用 Widget 测试验证样式继承

在写一个带左右固定装饰图标的自定义双 Tab 导航栏时,需求是根据每个 Tab 标题文字的实际宽度加上固定装饰间距,计算出容器所需的总宽度赋给外层 SizedBox,保证单行完整显示且切换选中态时左右不晃动。

初版实现中,通过 TextPainter 传入 TextStyle(fontSize: 15, fontWeight: FontWeight.w600) 调用 layout() 获取宽度,下方的 Text 组件也传入了相同的字号与字重。本地使用简短的中文标题测试时显示正常,但在真机窄屏上切换到较长的英文标题后,尽管外层容器宽度已按测量值设置,末尾字母仍然出现了裁切。

1. Text 与 TextPainter 的样式继承差异

对比渲染树中 Text 实际生效的样式与传给 TextPainter 的样式后,可以找到具体原因。

在 Flutter 的组件树里,Text 并不会直接使用传入的局部 style 进行排版。只要传入的 TextStyle 中 inherit 为默认的 true,Text.build 内部会先调用 DefaultTextStyle.of(context),把祖先主题(例如 AppBar、TabBar 或全局 ThemeData.textTheme)里声明的默认文本样式与局部 style 做一次 merge:

维度                  独立创建的 TextPainter           组件树中的 Text Widget
------------------------------------------------------------------------------------
显式传入属性          fontSize: 15, w600              fontSize: 15, w600
祖先主题 fontFamily   未获取(回退引擎默认字体)       自动继承 DefaultTextStyle 字体族
祖先 letterSpacing    null(按 0 字距紧凑排列)        自动继承主题设定的正数字距(如 0.5)
系统字体缩放          默认 TextScaler.noScaling        自动应用 MediaQuery.textScalerOf(context)

即使两边显式传入的 fontSize 和 fontWeight 完全一致,只要外层主题带有正数的 letterSpacing(字间距)或指定了字面较宽的自定义 fontFamily,三四个汉字的短标题总误差可能不足 2px,不易察觉;换成十几个字母的英文标题后,字距误差累加可达十余像素,按 TextPainter 偏小的结果设置 SizedBox 宽度,绘制阶段就会裁切末尾字符。

2. 修复前后代码对照:共用同一份最终排版上下文

只要某段文本的 TextPainter 测量结果会被用来当作布局约束(SizedBox、ConstrainedBox 或自定义绘制坐标),测量和实际渲染就必须拿到完全相同的最终 TextStyle 以及相同的缩放、方向与区域条件。

修复前的脱节写法:

// 修复前:只对齐了局部字号与字重,遗漏了 DefaultTextStyle 继承属性与系统缩放
final rawStyle = TextStyle(fontSize: 15, fontWeight: FontWeight.w600);
final painter = TextPainter(
  text: TextSpan(text: label, style: rawStyle),
  textDirection: TextDirection.ltr,
  maxLines: 1,
)..layout();

return SizedBox(
  width: painter.width + horizontalChromeWidth, // 在带正数字距的主题下宽度偏小
  child: Center(
    child: Text(label, style: rawStyle, maxLines: 1, softWrap: false),
  ),
);

修复后的对称写法(顺带处理了选中态加粗切换时的防跳动与 TextPainter 原生资源释放):

import 'dart:math' as math;
import 'package:flutter/widgets.dart';

TextStyle resolveEffectiveLabelStyle(
  BuildContext context, {
  required FontWeight fontWeight,
  Color? color,
}) {
  return DefaultTextStyle.of(context).style.merge(
    TextStyle(
      fontSize: 15,
      fontWeight: fontWeight,
      color: color,
    ),
  );
}

double measureSingleLineWidth(
  BuildContext context, {
  required String text,
  required TextStyle effectiveStyle,
  required TextScaler textScaler,
}) {
  final painter = TextPainter(
    text: TextSpan(text: text, style: effectiveStyle),
    textDirection: Directionality.of(context),
    textScaler: textScaler,
    locale: Localizations.maybeLocaleOf(context),
    maxLines: 1,
  )..layout();
  try {
    return painter.width.ceilToDouble();
  } finally {
    // Flutter 3.12+ 中 TextPainter 持有底层 Paragraph 原生对象,临时测量完应主动释放
    painter.dispose();
  }
}

Widget buildStableTabItem(
  BuildContext context, {
  required String label,
  required bool selected,
  required double horizontalChromeWidth,
}) {
  // 若该导航栏允许跟随系统字体放大,统一取 MediaQuery.textScalerOf(context);
  // 若产品明确要求导航栏字号固定,则测量与 Text 必须同时显式传入 TextScaler.noScaling。
  final scaler = MediaQuery.textScalerOf(context);
  final normalStyle = resolveEffectiveLabelStyle(context, fontWeight: FontWeight.w400);
  final selectedStyle = resolveEffectiveLabelStyle(context, fontWeight: FontWeight.w600);

  // 同时测量常规字重与选中加粗字重,取较大值预留槽位,避免点击切换 Tab 时整排发生横向抖动
  final reservedTextWidth = math.max(
    measureSingleLineWidth(
      context,
      text: label,
      effectiveStyle: normalStyle,
      textScaler: scaler,
    ),
    measureSingleLineWidth(
      context,
      text: label,
      effectiveStyle: selectedStyle,
      textScaler: scaler,
    ),
  );

  return SizedBox(
    width: reservedTextWidth + horizontalChromeWidth,
    child: Center(
      child: Text(
        label,
        style: selected ? selectedStyle : normalStyle,
        textScaler: scaler,
        textDirection: Directionality.of(context),
        locale: Localizations.maybeLocaleOf(context),
        maxLines: 1,
        softWrap: false,
      ),
    ),
  );
}

里面有三处一并处理的细节:

  • 对测量出的浮点宽度做 .ceilToDouble():字体光栅化与亚像素布局取整时,98.3px 的文本放进恰好 98.3px 的约束盒子里偶尔仍可能因亚像素舍入触发截断,向上取整到整像素更稳妥;
  • 选中态与未选中态取 math.max:切换选中常伴随 w400 → w600 的字重变化,提前按较宽的字重预留槽位,点击时相邻标签就不会横向跳动;
  • 在 finally 里调用 painter.dispose():TextPainter.layout() 底层会分配 ui.Paragraph 原生内存,放在 build 里频繁临时测量如果不主动 dispose,滑动或动画期间会产生不必要的原生对象堆积。

3. 使用 Widget 测试验证样式继承

这个问题很适合补充一条简短的 testWidgets 回归测试:在组件外层包裹一个较大的 DefaultTextStyle.merge(style: TextStyle(letterSpacing: 3.0)),断言最终分配给容器的宽度仍然大于等于 RenderParagraph 的实际排版宽度加上装饰预留。

testWidgets('外层 DefaultTextStyle 注入正数字距时,Tab 预留宽度仍足以容纳完整文本', (tester) async {
  const chromeWidth = 24.0;
  const label = 'Notification Settings';

  await tester.pumpWidget(
    Directionality(
      textDirection: TextDirection.ltr,
      child: DefaultTextStyle.merge(
        style: const TextStyle(letterSpacing: 3.0),
        child: Center(
          child: Builder(
            builder: (context) => buildStableTabItem(
              context,
              label: label,
              selected: true,
              horizontalChromeWidth: chromeWidth,
            ),
          ),
        ),
      ),
    ),
  );

  final boxWidth = tester.getSize(find.byType(SizedBox)).width;
  final textWidth = tester.getSize(find.text(label)).width;
  expect(boxWidth, greaterThanOrEqualTo(textWidth + chromeWidth));
});

如果后续重构时误删了 DefaultTextStyle.of(context).style.merge,这条单测会直接失败。另外需要注意,“测量宽度准确”仅保证容器自身没有低估文本宽度;如果遇到小屏手机加上超长多语言翻译,算出来的总宽度超过了屏幕可用宽度,外层仍需配合横向可滚动导航栏或最大宽度省略策略处理。