Flutter:如何创建可复用的填空式文本组件并优化实现
优化Flutter填空式文本输入组件的实现方案
你的需求是实现类似 I'm (2) _ _ _ _ _ . Today (3) _ _ sunny. _ _ _ 的填空输入组件,当前用Wrap嵌套Text和TextField的方案存在代码冗余、状态管理繁琐、用户输入体验差的问题,以下是几种更优的实现思路:
方案1:封装复用的填空输入组件(最小改动,解决代码冗余)
先将重复的单个填空输入框封装成独立组件,统一样式和逻辑,大幅减少重复代码,同时方便后续维护。
实现代码
class BlankInput extends StatelessWidget { final ValueChanged<String>? onChanged; final String? initialValue; const BlankInput({super.key, this.onChanged, this.initialValue}); @override Widget build(BuildContext context) { return SizedBox( width: 16, child: TextField( controller: initialValue != null ? TextEditingController(text: initialValue) : null, maxLength: 1, textAlign: TextAlign.center, style: const TextStyle(fontSize: 18, height: 1.5), decoration: const InputDecoration( isDense: true, contentPadding: EdgeInsets.only(top: 4), counterText: "", ), onChanged: onChanged, ), ); } } // 使用示例 return Scaffold( body: Wrap( spacing: 2, crossAxisAlignment: WrapCrossAlignment.center, // 对齐文本与输入框基线 children: [ const Text("I'm (2) ", style: TextStyle(fontSize: 18, height: 1.6)), BlankInput(onChanged: (val) { /* 处理第一个空位输入 */ }), BlankInput(onChanged: (val) { /* 处理第二个空位输入 */ }), BlankInput(onChanged: (val) { /* 处理第三个空位输入 */ }), BlankInput(onChanged: (val) { /* 处理第四个空位输入 */ }), BlankInput(onChanged: (val) { /* 处理第五个空位输入 */ }), const Text(". Today (3) ", style: TextStyle(fontSize: 18, height: 1.6)), BlankInput(onChanged: (val) { /* 处理第六个空位输入 */ }), BlankInput(onChanged: (val) { /* 处理第七个空位输入 */ }), const Text(" sunny. ", style: TextStyle(fontSize: 18, height: 1.6)), BlankInput(onChanged: (val) { /* 处理第八个空位输入 */ }), BlankInput(onChanged: (val) { /* 处理第九个空位输入 */ }), BlankInput(onChanged: (val) { /* 处理第十个空位输入 */ }), ], ), );
方案2:单个TextField实现(最优用户体验)
用一个TextField配合TextEditingController统一处理输入,通过自定义逻辑模拟填空位,用户无需切换输入框,体验更流畅,状态管理也更简单。
实现代码
class FillInTheBlank extends StatefulWidget { // 模板文本,用连续下划线标记空位 final String templateText; // 总空位数量 final int totalBlanks; const FillInTheBlank({ super.key, required this.templateText, required this.totalBlanks, }); @override State<FillInTheBlank> createState() => _FillInTheBlankState(); } class _FillInTheBlankState extends State<FillInTheBlank> { late TextEditingController _controller; // 存储每个空位的输入内容 final List<String> _blankInputs = []; @override void initState() { super.initState(); // 初始化空位输入列表 _blankInputs.addAll(List.generate(widget.totalBlanks, (_) => "")); // 构建初始显示文本,将模板下划线替换为空占位 String displayText = widget.templateText; for (int i = 0; i < widget.totalBlanks; i++) { displayText = displayText.replaceFirst(RegExp(r'_+'), _blankInputs[i].isEmpty ? "_" : _blankInputs[i]); } _controller = TextEditingController(text: displayText); } @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: TextField( controller: _controller, style: const TextStyle(fontSize: 18, height: 1.6), decoration: const InputDecoration( border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.zero, ), onChanged: (value) { setState(() { // 解析输入,提取每个空位的内容(可根据实际模板格式调整逻辑) List<String> parts = value.split(RegExp(r'(?<=_|(?=_))')); for (int i = 0; i < _blankInputs.length; i++) { if (i < parts.length && parts[i].isNotEmpty && !parts[i].startsWith("_")) { // 限制每个空位仅保留最后一个输入字符 _blankInputs[i] = parts[i].substring(parts[i].length - 1); } } // 重新构建显示文本 String newText = widget.templateText; for (int i = 0; i < _blankInputs.length; i++) { newText = newText.replaceFirst(RegExp(r'_+'), _blankInputs[i].isEmpty ? "_" : _blankInputs[i]); } // 更新控制器文本,保持光标位置 _controller.value = _controller.value.copyWith( text: newText, selection: TextSelection.collapsed(offset: _controller.selection.end), ); }); }, ), ); } @override void dispose() { _controller.dispose(); super.dispose(); } } // 使用示例 return Scaffold( body: Center( child: FillInTheBlank( templateText: "I'm (2) _____. Today (3) __ sunny. ___", totalBlanks: 5 + 2 + 3, // 对应模板中的空位数量 ), ), );
方案3:CustomPainter自定义绘制(高度UI定制)
如果需要精确控制下划线的样式、位置,可结合CustomPainter绘制下划线,搭配EditableText处理输入,适合对UI细节要求极高的场景。
实现代码(简化版)
class BlankPosition { final int start; final int length; BlankPosition({required this.start, required this.length}); } class BlankTextEditor extends StatefulWidget { final String content; final List<BlankPosition> blanks; const BlankTextEditor({ super.key, required this.content, required this.blanks, }); @override State<BlankTextEditor> createState() => _BlankTextEditorState(); } class _BlankTextEditorState extends State<BlankTextEditor> { late TextEditingController _controller; final FocusNode _focusNode = FocusNode(); @override void initState() { super.initState(); _controller = TextEditingController(text: widget.content); } @override Widget build(BuildContext context) { return CustomPaint( painter: BlankUnderlinePainter( blanks: widget.blanks, text: _controller.text, style: const TextStyle(fontSize: 18), ), child: EditableText( controller: _controller, focusNode: _focusNode, style: const TextStyle(fontSize: 18, height: 1.6), cursorColor: Colors.black, backgroundCursorColor: Colors.grey, onChanged: (value) => setState(() {}), ), ); } @override void dispose() { _controller.dispose(); _focusNode.dispose(); super.dispose(); } } class BlankUnderlinePainter extends CustomPainter { final List<BlankPosition> blanks; final String text; final TextStyle style; BlankUnderlinePainter({ required this.blanks, required this.text, required this.style, }); @override void paint(Canvas canvas, Size size) { final textPainter = TextPainter( text: TextSpan(text: text, style: style), textDirection: TextDirection.ltr, )..layout(maxWidth: size.width); final underlinePaint = Paint() ..color = Colors.black ..strokeWidth = 1.2 ..style = PaintingStyle.stroke; for (var blank in blanks) { final startOffset = textPainter.getOffsetForCaret( TextPosition(offset: blank.start), Rect.zero, ); final endOffset = textPainter.getOffsetForCaret( TextPosition(offset: blank.start + blank.length), Rect.zero, ); // 绘制下划线,调整位置与文本基线对齐 canvas.drawLine( Offset(startOffset.dx, startOffset.dy + style.fontSize! * 0.1), Offset(endOffset.dx, endOffset.dy + style.fontSize! * 0.1), underlinePaint, ); } } @override bool shouldRepaint(covariant BlankUnderlinePainter oldDelegate) { return oldDelegate.text != text || oldDelegate.blanks != blanks; } } // 使用示例 return Scaffold( body: Padding( padding: const EdgeInsets.all(16), child: BlankTextEditor( content: "I'm (2) . Today (3) sunny. ", blanks: [ BlankPosition(start: 6, length: 5), BlankPosition(start: 18, length: 2), BlankPosition(start: 28, length: 3), ], ), ), );
方案选型建议
- 若仅需解决代码冗余问题:选择方案1,改动最小,快速见效
- 若追求流畅的用户输入体验:选择方案2,单个输入框无需切换,逻辑更简洁
- 若需要高度定制UI样式:选择方案3,可精确控制下划线的样式和位置
内容的提问来源于stack exchange,提问作者18Typn
相关产品推荐
相关产品推荐

