You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.28 14:47:13