Flutter Add TextField widget dynamically

eye-catchDart and Flutter
Sponsored links

TextField requires TextEditingController that must be disposed. If we want to add TextField dynamically we also need to call dispose function. How can we implement this? Another case is when we use Future class to prepare data. If we need to retrieve data from a database and put as many TextField widgets as the number of the data, we need to handle Future class correctly.

Following is the base code.

  • _View1 is to add TextField when touching a button
  • _View2 is to add TextField by using FutureBuilder
  • _View3 is to add Multiple TextField at once to handle them as a group
  • _View4 is basically the same as _View3 but in a different way
class DynamicTextFieldView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: PageView(
        children: [
          _View1(),
          _View2(),
          _View3(),
          _View4(),
        ],
      ),
    );
  }
}
Sponsored links

How to add TextField dynamically

Let’s see the behavior of the view first.

The base code for the view is following. Add button is at the top and OK button is at the bottom.

class _View1 extends StatefulWidget {
  @override
  _View1State createState() => _View1State();
}

class _View1State extends State<_View1> {
  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Scaffold(
          appBar: AppBar(
            title: Text("Dynamic Text Field"),
          ),
          body: Column(
            children: [
              _addTile(),
              Expanded(child: _listView()),
              _okButton(),
            ],
          )),
    );
  }
}

If we don’t use Expanded widget we get the following error.

════════ Exception caught by rendering library ═════════════════════════════════
RenderBox was not laid out: RenderRepaintBoundary#32262 relayoutBoundary=up2 NEEDS-PAINT
'package:flutter/src/rendering/box.dart':
Failed assertion: line 1930 pos 12: 'hasSize'

The relevant error-causing widget was
Column
lib\DynamicTextField.dart:42
════════════════════════════════════════════════════════════════════════════════

A button to add new TextField

We need to create TextEditingController instance when touching an add button. Let’s see the code for it.

class _View1State extends State<_View1> {
  List<TextEditingController> _controllers = [];
  List<TextField> _fields = [];

  @override
  void dispose() {
    for (final controller in _controllers) {
      controller.dispose();
    }
    super.dispose();
  }

  Widget _addTile() {
    return ListTile(
      title: Icon(Icons.add),
      onTap: () {
        final controller = TextEditingController();
        final field = TextField(
          controller: controller,
          decoration: InputDecoration(
            border: OutlineInputBorder(),
            labelText: "name${_controllers.length + 1}",
          ),
        );

        setState(() {
          _controllers.add(controller);
          _fields.add(field);
        });
      },
    );
  }
}

We create new TextEditingController and TextField instances in onTap event of ListTile and push them into List accordingly. It process needs to be done in setState callback because we want to update the variables and reflect the change to the view. After calling setState function, build function is executed again.
Each List has as many instances as the number of times we tap the button. Since TextEditingController must be disposed we have to override dispose function. If we forget to define it memory leak occurs.

Creating ListView

The next step is creating ListView. It’s simple code. We already know how many TextField widgets we have. Let’s use _fields variable.

Widget _listView() {
  return ListView.builder(
    itemCount: _fields.length,
    itemBuilder: (context, index) {
      return Container(
        margin: EdgeInsets.all(5),
        child: _fields[index],
      );
    },
  );
}

We can of course create TextField instance here instead of using _fields variable. However, I didn’t do it because it might cause a memory leak. I haven’t tested it but I’m not sure if the TextField widget is disposed when calling setState function. Does framework call dispose function of the TextField created in ListView.builder since the instance is not referred to? Please leave a comment if you know the answer.

Consume the values

The last step is OK button that is a consumer of the values. It just concatenates each value and shows it on a dialog. The List of controllers is ordered so we know which value is for which but this code doesn’t do it.

Widget _okButton() {
  return ElevatedButton(
    onPressed: () async {
      String text = _controllers
          .where((element) => element.text != "")
          .fold("", (acc, element) => acc += "${element.text}\n");
      final alert = AlertDialog(
        title: Text("Count: ${_controllers.length}"),
        content: Text(text.trim()),
        actions: [
          TextButton(
            onPressed: () {
              Navigator.of(context).pop();
            },
            child: Text("OK"),
          ),
        ],
      );
      await showDialog(
        context: context,
        builder: (BuildContext context) => alert,
      );
      setState(() {});
    },
    child: Text("OK"),
  );
}

This is the all for adding TextField dynamically.

Sponsored links

How to handle TextEditingController with FutureBuilder

We need to handle it differently when we want to retrieve data from the database and put as many TextField widgets as the number of the data. Let’s define Map instead of List to store TextEditingControllers.

class _View2 extends StatefulWidget {
  @override
  _View2State createState() => _View2State();
}

class _View2State extends State<_View2> {
  Map<String, TextEditingController> _controllerMap = Map();

  @override
  void dispose() {
    _controllerMap.forEach((_, controller) => controller.dispose());
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Scaffold(
          appBar: AppBar(
            title: Text("Dynamic Text Field with async"),
          ),
          body: Column(
            children: [
              Expanded(child: _futureBuilder()),
              _cancelOkButton(),
            ],
          )),
    );
  }
}

TextField with FutureBuilder

I wrote the following code for the replacement of the database.

List<String> _data = [
  "one",
  "two",
  "three",
  "four",
];
Future<List<String>> _retrieveData() {
  return Future.value(_data);
}

If we retrieve data from the database it is probably Future type. In this case, we need to put FutureBuilder widget.
The inside of AsyncSnapshot can’t be null in this example but it can be null in real with SQL query. That’s why I wrote in this way.

Widget _futureBuilder() {
  return FutureBuilder(
    future: _retrieveData(),
    builder: (BuildContext context, AsyncSnapshot<List<String>?> snapshot) {
      if (!snapshot.hasData) {
        return Align(
          alignment: Alignment.topCenter,
          child: Text("No item"),
        );
      }

      final data = snapshot.data!;
      return ListView.builder(
        itemCount: data.length,
        padding: EdgeInsets.all(5),
        itemBuilder: (BuildContext context, int index) {
          final controller = _getControllerOf(data[index]);

          final textField = TextField(
            controller: controller,
            decoration: InputDecoration(
              border: OutlineInputBorder(),
              labelText: "name${index + 1}",
            ),
          );
          return Container(
            child: textField,
            padding: EdgeInsets.only(bottom: 10),
          );
        },
      );
    },
  );
}

It gets a controller and assigns it to the new TextField widget. _getControllerOf is following. It gets an existing controller if it exists. Otherwise, it creates a new controller with initial text and stores it.

TextEditingController _getControllerOf(String name) {
  var controller = _controllerMap[name];
  if (controller == null) {
    controller = TextEditingController(text: name);
    _controllerMap[name] = controller;
  }
  return controller;
}

This code assumes that each TextField has unique text. If it doesn’t fit your case you need to adjust it by creating a unique ID for each TextField depending on your specification. In this case, you might want to create a class with the unique ID and store it in a List instead of Map.

Consume the values

The cancel button and OK button are located at the bottom. The code is following.

Widget _cancelOkButton() {
  return Row(
    mainAxisSize: MainAxisSize.max,
    mainAxisAlignment: MainAxisAlignment.spaceBetween,
    children: [
      _cancelButton(),
      _okButton(),
    ],
  );
}

The cancel button clears the changes we made. The map has an initial value as a key. If we want to set the initial value we can use it as follows.

Widget _cancelButton() {
  return ElevatedButton(
    onPressed: () {
      setState(() {
        _controllerMap.forEach((key, controller) {
          controller.text = key;
        });
      });
    },
    child: Text("Cancel"),
  );
}

This code concatenates the values and shows them on a dialog. Then, it updates the values which are in a dummy database table.

Widget _okButton() {
  return ElevatedButton(
    onPressed: () async {
      String text = _controllerMap.values
          .where((element) => element.text != "")
          .fold("", (acc, element) => acc += "${element.text}\n");
      await _showUpdatedDialog(text);

      setState(() {
        _controllerMap.forEach((key, controller) {
          // get the index of original text
          int index = _controllerMap.keys.toList().indexOf(key);
          key = controller.text;
          _data[index] = controller.text;
        });
      });
    },
    child: Text("OK"),
  );
}

The code for dialog looks like this.

Future _showUpdatedDialog(String text) {
  final alert = AlertDialog(
    title: Text("Updated"),
    content: Text(text.trim()),
    actions: [
      TextButton(
        onPressed: () {
          Navigator.of(context).pop();
        },
        child: Text("OK"),
      ),
    ],
  );
  return showDialog(
    context: context,
    builder: (BuildContext context) => alert,
  );
}

Let’s see how it works.

Dynamic Grouping TextField

If we want to handle multiple TextField as a group, we need to of course define multiple controllers. Let’s have the following 3 items in this example.

  • Name
  • Tel
  • Address

Define 3 controllers

Let’s define them.

class _View3 extends StatefulWidget {
  @override
  _View3State createState() => _View3State();
}

class _View3State extends State<_View3> {
  List<TextEditingController> _nameControllers = [];
  List<TextField> _nameFields = [];
  List<TextEditingController> _telControllers = [];
  List<TextField> _telFields = [];
  List<TextEditingController> _addressControllers = [];
  List<TextField> _addressFields = [];
  ...

To treat them as a group, we have to access the variable with the same index. If we want to get the second group’s data, we need to access it like this.

final name = _nameControllers[1].text;
final tel = _telControllers[1].text;
final address = _addressControllers[1].text;

We also need to call dispose for all controllers.

@override
void dispose() {
  for (final controller in _nameControllers) {
    controller.dispose();
  }
  for (final controller in _telControllers) {
    controller.dispose();
  }
  for (final controller in _addressControllers) {
    controller.dispose();
  }
  _okController.dispose();
  super.dispose();
}

We need to define the controllers one by one.

Widget _addTile() {
  return ListTile(
    title: Icon(Icons.add),
    onTap: () {
      final name = TextEditingController();
      final tel = TextEditingController();
      final address = TextEditingController();

      final nameField = _generateTextField(name, "name");
      final telField = _generateTextField(tel, "mobile");
      final addressField = _generateTextField(address, "address");

      setState(() {
        _nameControllers.add(name);
        _telControllers.add(tel);
        _addressControllers.add(address);
        _nameFields.add(nameField);
        _telFields.add(telField);
        _addressFields.add(addressField);
      });
    },
  );
}

TextField _generateTextField(TextEditingController controller, String hint) {
  return TextField(
    controller: controller,
    decoration: InputDecoration(
      border: OutlineInputBorder(),
      labelText: hint,
    ),
  );
}

Then, show the 3 TextField as a group. Access the TextField lists with the same index.

Widget _listView() {
  final children = [
    for (var i = 0; i < _nameControllers.length; i++)
      Container(
        margin: EdgeInsets.all(5),
        child: InputDecorator(
          child: Column(
            children: [
              _nameFields[i],
              _telFields[i],
              _addressFields[i],
            ],
          ),
          decoration: InputDecoration(
            labelText: i.toString(),
            border: OutlineInputBorder(
              borderRadius: BorderRadius.circular(10.0),
            ),
          ),
        ),
      )
  ];
  return SingleChildScrollView(
    child: Column(
      children: children,
    ),
  );
}

The index of the List for a group is the same for List<TextEditingController> and List<TextField>. Therefore, we can read the corresponding text with the same index.

  final _okController = TextEditingController();
  Widget _okButton(BuildContext context) {
    final textField = TextField(
      controller: _okController,
      keyboardType: TextInputType.number,
      decoration: InputDecoration(
        border: OutlineInputBorder(),
      ),
    );

    final button = ElevatedButton(
      onPressed: () async {
        final index = int.parse(_okController.text);
        String text = "name: ${_nameControllers[index].text}\n" +
            "tel: ${_telControllers[index].text}\n" +
            "address: ${_addressControllers[index].text}";
        await showMessage(context, text, "Result");
      },
      child: Text("OK"),
    );

    return Row(
      mainAxisSize: MainAxisSize.max,
      mainAxisAlignment: MainAxisAlignment.spaceAround,
      children: [
        Container(
          child: textField,
          width: 100,
          height: 50,
        ),
        button,
      ],
    );
  }
}

Define a class that has 3 controllers

The previous implementation works but I want to have a class that has those 3 controllers. It is more readable.

class _GroupControllers {
  TextEditingController name = TextEditingController();
  TextEditingController tel = TextEditingController();
  TextEditingController address = TextEditingController();
  void dispose() {
    name.dispose();
    tel.dispose();
    address.dispose();
  }
}

We can create 3 controllers at once and dispose needs to be called once for those 3. It’s simpler than before.

Widget _addTile() {
  return ListTile(
    title: Icon(Icons.add),
    onTap: () {
      final group = _GroupControllers();

      final nameField = _generateTextField(group.name, "name");
      final telField = _generateTextField(group.tel, "mobile");
      final addressField = _generateTextField(group.address, "address");

      setState(() {
        _groupControllers.add(group);
        _nameFields.add(nameField);
        _telFields.add(telField);
        _addressFields.add(addressField);
      });
    },
  );
}

The usage is almost the same as before.

final button = ElevatedButton(
  onPressed: () async {
    final index = int.parse(_okController.text);
    String text = "name: ${_groupControllers[index].name.text}\n" +
        "tel: ${_groupControllers[index].tel.text}\n" +
        "address: ${_groupControllers[index].address.text}\n";
    await showMessage(context, text, "Result");
  },
  child: Text("OK"),
);

Dynamic TextField with Riverpod

Check the following post if you are looking for a way to implement it with Riverpod.

End

If you want to try it yourself you can clone my repository.

flutter_samples/dynamic_text_field.dart at main · yuto-yuto/flutter_samples
Contribute to yuto-yuto/flutter_samples development by creating an account on GitHub.

Comments

  1. Dayo says:

    PLease where is the full source code, i can’t find it on the repo you shared

Copied title and URL