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

Flutter循环创建TextFormField值同步无法独立输入问题求解

问题说明

开发个人健身训练类应用时,已实现点击按钮动态新增文本输入行的功能,但存在两个异常:

  • 所有循环创建的TextFormField输入值始终保持同步,无法输入不同内容
  • 调整后虽可实现各输入框独立输入不同值,但无法正常保存输入内容

问题效果截图:
输入框值同步异常截图

原有实现代码存在TextEditingController使用逻辑错误,完整原代码如下:

class EventEditingPage extends StatefulWidget {
  final Event? event;

  const EventEditingPage({Key? key, this.event}) : super(key: key);

  @override
  _EventEditingPageState createState() => _EventEditingPageState();
}

class _EventEditingPageState extends State<EventEditingPage> {
  final _formKey = GlobalKey<FormState>();
  final setsController = TextEditingController();
  final repsController = TextEditingController();
  final loadController = TextEditingController();
  late DropdownButton Exercise; 

  @override
  void initState() {
    super.initState();
      final event = widget.event!;

      exercise = event.Exercise;
      setsController.text = event.Sets;
      repsController.text = event.Reps;
      loadController.text = event.Load;
    }
  }

  @override
  void dispose() {
    setsController.dispose();   
    repsController.dispose();
    loadController.dispose();
    super.dispose();
  }
  
int numberOfTextFields = 1;
  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(
          leading: CloseButton(),
          actions: buildEditingActions(),
          backgroundColor: Colors.red,
        ),
        body: SingleChildScrollView(
          padding: EdgeInsets.all(12),
          child: Form(
            key: _formKey,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
                Text("text", style: TextStyle(fontSize: 24)),
                SizedBox(height: 12),
                Text("\ntext", style: TextStyle(fontSize: 24)),
                for(int i = 0 ; i < numberOfTextFields ; i++) 
                buildExerciseComplete(),
                buildAddMore(),
              ],
            ),
          ),
        ),
      );

  List<Widget> buildEditingActions() => [
        ElevatedButton.icon(
          style: ElevatedButton.styleFrom(
              primary: Colors.transparent, shadowColor: Colors.transparent),
          onPressed: saveForm,
          icon: Icon(Icons.done),
          label: Text('Save'),
        )
      ];


  Widget buildExerciseComplete() => Container(
        padding: EdgeInsets.all(5.0),
        decoration: BoxDecoration(
          color: Color.fromARGB(255, 255, 200, 200),
          borderRadius: BorderRadius.circular(5.0),
          border: Border.all(
            width: 2.0,
          ),
        ),
        height: 50,
        child: Row(
          children: <Widget>[
            Expanded(
              child: Row(
                children: <Widget>[
                  Expanded(child: buildExercise()),
                  Expanded(child: buildSets()),
                  Expanded(child: buildReps()),
                  Expanded(child: buildLoad()),
                ],
              ),
            ),
          ],
        ),
      );

  Widget buildSets() => TextFormField(
        keyboardType: TextInputType.number,
        inputFormatters: <TextInputFormatter>[
          FilteringTextInputFormatter.digitsOnly
        ],
        style: TextStyle(fontSize: 15),
        decoration: InputDecoration(
          border: OutlineInputBorder(),
          labelText: 'Sets',
        ),
        onFieldSubmitted: (_) => saveForm,
        validator: (sets) =>
            sets != null && sets.isEmpty ? 'Sets cannot be empty' : null,
             controller: setsController,
      );

  Widget buildReps() => TextFormField(
        keyboardType: TextInputType.number,
        inputFormatters: <TextInputFormatter>[
          FilteringTextInputFormatter.digitsOnly
        ],
        style: TextStyle(fontSize: 15),
        decoration: InputDecoration(
          border: OutlineInputBorder(),
          labelText: 'Reps',
        ),
        onFieldSubmitted: (_) => saveForm,
        validator: (reps) =>
            reps != null && reps.isEmpty ? 'Reps cannot be empty' : null,
        controller: repsController,
      );

  Widget buildLoad() => TextFormField(
        keyboardType: TextInputType.number,
        inputFormatters: <TextInputFormatter>[
          FilteringTextInputFormatter.digitsOnly
        ],
        style: TextStyle(fontSize: 15),
        decoration: InputDecoration(
          labelText: 'Load',
          border: OutlineInputBorder(),
        ),
        onFieldSubmitted: (_) => saveForm,
        validator: (load) =>
            load != null && load.isEmpty ? 'Load cannot be empty' : null,
        controller: loadController,
      );

  String exercise = 'Squat';
  Widget buildExercise() => DropdownButton<String>(
        value: exercise,
        icon: const Icon(Icons.arrow_drop_down),
        elevation: 16,
        style: const TextStyle(color: Colors.black),
        onChanged: (String? newValue) {
          setState(() {
            exercise = newValue!;
          });
        },
        items: <String>['Squat', 'Deadlift', 'Bench']
            .map<DropdownMenuItem<String>>((String value) {
          return DropdownMenuItem<String>(
            value: value,
            child: Text(value),
          );
        }).toList(),
      );

  Widget buildAddMore() => ElevatedButton(
            onPressed: () {
              setState((){
        numberOfTextFields++;
        _textEditingControllers.add(TextEditingController());
    });
            },
            child: Text('Add new exercise'),  
          );
        
  
  Future saveForm() async {
    final isValid = _formKey.currentState!.validate();

    if (isValid) {
      final event = Event(
          Exercise: exercise,
          Sets: setsController.text,
          Reps: repsController.text,
          Load: loadController.text);

      final isEditing = widget.event != null;
      final provider = Provider.of<EventProvider>(context, listen: false);
      if (isEditing) {
        provider.editEvent(event, widget.event!);

        Navigator.of(context).pop();
      } else {
        provider.addEvent(event);
      }

      Navigator.of(context).pop();
    }
  }
}

class Event {
  final Exercise;
  final String Sets;
  final String Reps;
  final String Load;
  final Color backgroundColor;


  const Event({
     required this.Sets,
    required this.Reps,
    required this.Load,
    this.backgroundColor = Colors.lightGreen,
  });
}
问题根因

问题确实出在TextEditingController的使用逻辑上:

  • 全局只定义了3个TextEditingController,所有动态生成的Sets、Reps、Load输入框都绑定到同一个控制器,输入内容自然完全同步
  • 下拉选择框共用全局的exercise变量,切换任意一行的动作,所有行的选项都会同步变更
  • 代码中声明的_textEditingControllers列表仅做了添加控制器的操作,既没有和对应输入框绑定,也没有为每一行单独存储动作、重量等状态,所以无法正常保存多行内容
  • 原有Event类的Exercise字段未声明类型、未加必填修饰,会触发类型错误
修复方案

核心思路是为每一行动态生成的输入项分配独立的控制器和状态存储,不要全局共用单例对象:

  1. 定义单组训练项的数据结构,存储当前行动作选择、三个输入框的独立控制器
  2. 页面初始化时,编辑模式加载已有数据,新建模式默认生成1组空输入项
  3. 点击新增按钮时,生成一组新的独立控制器和状态加入列表
  4. 渲染输入行时按索引读取对应行的控制器和状态,绑定到对应输入组件
  5. 页面销毁时遍历所有控制器统一释放,避免内存泄漏
  6. 保存时遍历所有行的控制器,逐行读取输入值存储
  7. 补全Event类的字段类型声明

修复后核心代码

首先定义单组训练项结构,修改页面状态的初始化、销毁逻辑:

// 单组训练项存储结构
class ExerciseEntry {
  String exercise;
  final TextEditingController setsController;
  final TextEditingController repsController;
  final TextEditingController loadController;

  ExerciseEntry({
    required this.exercise,
    required this.setsController,
    required this.repsController,
    required this.loadController,
  });
}

class _EventEditingPageState extends State<EventEditingPage> {
  final _formKey = GlobalKey<FormState>();
  // 存储所有动态行的独立状态与控制器
  List<ExerciseEntry> exerciseEntries = [];

  @override
  void initState() {
    super.initState();
    if (widget.event != null) {
      // 编辑模式加载已有数据
      final event = widget.event!;
      exerciseEntries.add(ExerciseEntry(
        exercise: event.Exercise,
        setsController: TextEditingController(text: event.Sets),
        repsController: TextEditingController(text: event.Reps),
        loadController: TextEditingController(text: event.Load),
      ));
    } else {
      // 新建模式默认初始化1组空输入
      _addNewEntry();
    }
  }

  // 新增一组空输入项
  void _addNewEntry() {
    exerciseEntries.add(ExerciseEntry(
      exercise: 'Squat',
      setsController: TextEditingController(),
      repsController: TextEditingController(),
      loadController: TextEditingController(),
    ));
  }

  @override
  void dispose() {
    // 统一释放所有控制器,避免内存泄漏
    for (final entry in exerciseEntries) {
      entry.setsController.dispose();
      entry.repsController.dispose();
      entry.loadController.dispose();
    }
    super.dispose();
  }

修改页面构建逻辑,循环渲染时传入行索引:

@override
Widget build(BuildContext context) => Scaffold(
      appBar: AppBar(
        leading: CloseButton(),
        actions: buildEditingActions(),
        backgroundColor: Colors.red,
      ),
      body: SingleChildScrollView(
        padding: EdgeInsets.all(12),
        child: Form(
          key: _formKey,
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: <Widget>[
              Text("text", style: TextStyle(fontSize: 24)),
              SizedBox(height: 12),
              Text("\ntext", style: TextStyle(fontSize: 24)),
              // 按遍历索引渲染对应行
              for(int i = 0 ; i < exerciseEntries.length ; i++) 
                buildExerciseComplete(i),
              buildAddMore(),
            ],
          ),
        ),
      ),
    );

修改输入行、输入框、下拉框组件,绑定对应行的独立状态:

Widget buildExerciseComplete(int index) {
  final currentEntry = exerciseEntries[index];
  return Container(
    padding: EdgeInsets.all(5.0),
    decoration: BoxDecoration(
      color: Color.fromARGB(255, 255, 200, 200),
      borderRadius: BorderRadius.circular(5.0),
      border: Border.all(width: 2.0),
    ),
    height: 50,
    child: Row(
      children: <Widget>[
        Expanded(
          child: Row(
            children: <Widget>[
              Expanded(child: buildExerciseDropdown(index, currentEntry)),
              Expanded(child: buildSetsField(currentEntry.setsController)),
              Expanded(child: buildRepsField(currentEntry.repsController)),
              Expanded(child: buildLoadField(currentEntry.loadController)),
            ],
          ),
        ),
      ],
    ),
  );
}

Widget buildSetsField(TextEditingController controller) => TextFormField(
      keyboardType: TextInputType.number,
      inputFormatters: <TextInputFormatter>[
        FilteringTextInputFormatter.digitsOnly
      ],
      style: TextStyle(fontSize: 15),
      decoration: InputDecoration(
        border: OutlineInputBorder(),
        labelText: 'Sets',
      ),
      validator: (sets) =>
          sets != null && sets.isEmpty ? 'Sets cannot be empty' : null,
      controller: controller,
    );

// buildRepsField、buildLoadField逻辑与buildSetsField一致,替换对应标签、校验提示即可

Widget buildExerciseDropdown(int index, ExerciseEntry entry) => DropdownButton<String>(
      value: entry.exercise,
      icon: const Icon(Icons.arrow_drop_down),
      elevation: 16,
      style: const TextStyle(color: Colors.black),
      onChanged: (String? newValue) {
        setState(() {
          entry.exercise = newValue!;
        });
      },
      items: <String>['Squat', 'Deadlift', 'Bench']
          .map<DropdownMenuItem<String>>((String value) {
        return DropdownMenuItem<String>(
          value: value,
          child: Text(value),
        );
      }).toList(),
    );

修改新增按钮、保存逻辑,补全Event类声明:

Widget buildAddMore() => ElevatedButton(
      onPressed: () {
        setState((){
          _addNewEntry();
        });
      },
      child: Text('Add new exercise'),  
    );

Future saveForm() async {
  final isValid = _formKey.currentState!.validate();
  if (!isValid) return;

  final provider = Provider.of<EventProvider>(context, listen: false);
  // 遍历所有行读取输入内容
  List<Event> events = exerciseEntries.map((entry) => Event(
    Exercise: entry.exercise,
    Sets: entry.setsController.text,
    Reps: entry.repsController.text,
    Load: entry.loadController.text,
  )).toList();

  final isEditing = widget.event != null;
  if (isEditing) {
    provider.editEvent(events.first, widget.event!);
  } else {
    // 批量保存所有训练项
    for (final event in events) {
      provider.addEvent(event);
    }
  }
  Navigator.of(context).pop();
}

// 补全Event类的字段声明
class Event {
  final String Exercise;
  final String Sets;
  final String Reps;
  final String Load;
  final Color backgroundColor;

  const Event({
    required this.Exercise,
    required this.Sets,
    required this.Reps,
    required this.Load,
    this.backgroundColor = Colors.lightGreen,
  });
}

内容的提问来源于stack exchange,提问作者Ko1ind

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.30 17:57:16