在移动端文本输入场景中,微信的输入体验被广泛认可:输入框高度随内容自动扩展,当内容超出一定行数时,自动显示全屏入口按钮,点击后可进入全屏编辑模式。本文介绍一种基于 TextPainter + GlobalKey 的精确行数计算方案,实现零延迟、无抖动、自然流畅的切换体验。
一、背景与痛点
常见的输入框实现问题
方案一:固定高度 TextField
TextField(maxLines: 3)
- 只能限制最大行
方案二:minLines + maxLines(Flutter 内置)
TextField(minLines: 1, maxLines: 6)
- 看似解决了自适应,但无法知道内部实际渲染了多少行
- 无法在这个临界点插入"全屏入口"按钮
实际需求场景
在聊天与工具类应用中,用户经常需要输入长数据:
单行不够用,6 行 TextField 放不下,此时用户最自然的诉求就是:点击一个按钮,切到全屏输入。
核心挑战
- Flutter TextField 不暴露内部渲染行数 — 需要自行计算
- TextField 实际渲染宽度受内边距、边框影响 — 需要在渲染后通过 GlobalKey 获取
二、核心实现
1. 状态变量
// 右侧Column内放大图标的显隐状态
bool _rightSideFullscreenIcon = false;
// 输入框实际宽度(用于 TextPainter 计算)
double _inputFieldWidth = 0.0;
// 输入框行数计算用的 GlobalKey(用于获取 TextField 实际渲染宽度)
final GlobalKey _inputFieldKey = GlobalKey();
三个变量各司其职:
_rightSideFullscreenIcon:控制全屏图标显示与否的开关_inputFieldWidth:TextPainter 计算时的实际可用宽度_inputFieldKey:获取 TextField 渲染后精确尺寸的桥梁
2. 精确计算自动换行的行数 — TextPainter
Flutter 的 TextField 在渲染完成后,可以通过 TextPainter 模拟相同的渲染过程,从而精确计算出给定宽度下的文本行数。
/// 使用 TextPainter 精确计算给定文本在指定宽度下的行数
int _calculateLineCount(String text, double maxWidth, TextStyle style) {
if (text.isEmpty) return 0;
final painter = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: null,
textDirection: TextDirection.ltr,
);
painter.layout(maxWidth: maxWidth);
return painter.computeLineMetrics().length;
}
关键点解读:
| 参数 | 作用 |
|---|---|
maxLines: null | 不限制最大行数,让 TextPainter 完整渲染,计算真实换行次数 |
maxWidth | 必须是 TextField 实际可用宽度,而非屏幕宽度 |
computeLineMetrics().length | 返回渲染后的真实行数 |
3. onChanged — 实时判断行数并更新状态
onChanged: (value) {
// 第一步:通过 GlobalKey 获取 TextField 渲染后的实际宽度
final RenderBox? renderBox = _inputFieldKey.currentContext
?.findRenderObject() as RenderBox?;
if (renderBox != null) {
_inputFieldWidth = renderBox.size.width;
}
// 第二步:计算实际可用宽度(减去输入框内边距与输入框自带边框 36px)
final double availableWidth = _inputFieldWidth - 36;
// 第三步:用 TextPainter 计算当前文本行数
final lineCount =
_calculateLineCount(value, availableWidth, inputStyle);
// 第四步:超过 3 行则显示全屏图标
final newState = lineCount > 3;
if (newState != _rightSideFullscreenIcon) {
setState(() {
_rightSideFullscreenIcon = newState;
});
}
},
为什么要减 36px?
TextField 的 maxWidth 应该是其内部文本的实际渲染宽度,要减去输入框的内边距与边框宽度
4. 布局结构
Column (mainAxisSize: min)
└── IntrinsicHeight
└── Row (crossAxisAlignment: end, mainAxisSize: min)
├── Expanded
│ └── TextField (minLines: 1, maxLines: 6)
│ └── key: _inputFieldKey
└── SizedBox (width: 60)
└── Column
├── 全屏图标 (条件渲染,_rightSideFullscreenIcon)
└── 发送按钮 (固定显示)
使用 IntrinsicHeight 包住 Row,让Row的子组件高度相同
右侧 Column 的动态对齐策略:
mainAxisAlignment: _rightSideFullscreenIcon
? MainAxisAlignment.spaceBetween // 有图标时:上下分布
: MainAxisAlignment.end // 无图标时:底部对齐
5. 全屏输入页面
class FullScreenInputPage extends StatefulWidget {
final String initialText; // 传入当前 TextField 内容
final ValueChanged<String> onTextChanged; // 实时同步回原输入框
final ValueChanged<String> onSend; // 发送回调
// ...
}
class _FullScreenInputPageState extends State<FullScreenInputPage> {
late TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialText);
// 自动聚焦并定位到末尾
WidgetsBinding.instance.addPostFrameCallback((_) {
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length),
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
// 顶部导航栏(标题居中,左侧关闭按钮)
// 错误提示(条件渲染)
// Expanded 主体输入区(maxLines: null, expands: true)
// 底部操作栏(格式选择 + 发送按钮)
],
),
),
);
}
}
全屏页面的两个核心交互:
- 输入实时同步:TextField 每一次 onChanged 都通过
onTextChanged回调回传内容,即使在全屏页面返回后,原 TextField 内容也保持一致。 - 发送后关闭:点击发送按钮时,调用
onSend后主动Navigator.pop(context),全屏页关闭,焦点回到原页面。
三、总结
本文的核心在于解决一个看似简单但实际有坑的问题:如何知道 TextField 当前渲染了多少行?
通过 GlobalKey + RenderBox 获取实际渲染宽度,再用 TextPainter 模拟渲染计算行数,最后用状态变量驱动 UI 切换 — 这套组合拳构成了完整的解决方案。
关键要点:
TextPainter.computeLineMetrics().length是获取精确行数的唯一可靠方式- TextField 的
maxWidth应该是其内部文本的实际渲染宽度,要减去输入框的内边距与边框宽度 - 使用 IntrinsicHeight 包住 Row,让Row的子组件高度相同
- 全屏页面通过回调实时同步内容,返回后数据不丢失