TextPainter 测量够宽,Text 为什么仍然被裁切?
定位自定义 Tab 单行标题在真机被截断的原因:TextPainter 不会自动继承组件树的 DefaultTextStyle 与字体缩放,测量与绘制必须共用最终样式。
在写一个带左右固定装饰图标的自定义双 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,这条单测会直接失败。另外需要注意,“测量宽度准确”仅保证容器自身没有低估文本宽度;如果遇到小屏手机加上超长多语言翻译,算出来的总宽度超过了屏幕可用宽度,外层仍需配合横向可滚动导航栏或最大宽度省略策略处理。