A Study in Flutter: Dynamic Forms

A Study in Flutter: Dynamic Forms

A recent requirement came up for configurable dynamic forms in a mobile application. There are a few dynamic form packages for Flutter, but I wanted a bit more control over the schema and behavior of the form. Here follows my attempt at creating dynamic forms with Flutter.

The JSON Schema

The first challenge we have to solve is the JSON schema for the form. We need to be able to specify the list of fields for the form as well as the following for each field:

  • type - Text or a Dropdown list

  • label - The label to display for the field e.g. Your Name

  • key - An internal use id for the field

  • validations - A list of validations for the field e.g. required, min/max length, etc

The schema I came up with looks like this:

{
  "id": 1,
  "name": "Contact Details Form",
  "fields": [
    {
      "type": "TextBox",
      "label": "Full Name",
      "key": "fullName",
      "validations": {
        "required": true,
        "minLength": 3,
        "maxLength": 50
      }
    },
    {
      "type": "TextBox",
      "label": "Email",
      "key": "email",
      "validations": {
        "required": true,
        "email": true
      }
    },
    {
      "type": "TextBox",
      "label": "Age",
      "key": "age",
      "validations": {
        "required": false,
        "numeric": true
      }
    },
    {
      "type": "DropdownList",
      "label": "Province",
      "key": "province",
      "options": [
        "Eastern Cape",
        "Free State",
        "Gauteng",
        "KwaZulu-Natal",
        "Limpopo",
        "Mpumalanga",
        "Northern Cape",
        "North West",
        "Western Cape"
      ],
      "validations": {"required": true}
    }
  ]
}

Validation Types

Most of the properties are pretty self-explanatory, but let's take a closer look at the validations property. To keep things relatively simple, we'll only have 5 types of validations:

  • required - Whether the field is required or not.

  • minLength - The minimum number of characters required.

  • maxLength - The maximum number of allowed characters.

  • email - The field must be a valid email address.

  • numeric - The field must be a valid number.

Packages

To make things a bit easier, I decided to use the flutter_form_builder package as well as its companion form_builder_validators package.

The flutter_form_builder removes a lot of the boilerplate code needed for forms and the form_builder_validators package has a nice list of pre-built validators, which is perfect for our use case.

The Flutter App

I won't bore you with the full details of creating the Flutter app, but will only focus on the key parts.

The Home Screen

The Home screen makes up the entirety of the app. It's made up of a Scaffold, whose body contains only a FutureBuilder, which is used to load the data for the form (I'm not using any state management package for this app). It also has a FloatingActionButton, which we'll use to validate and save the form. The code listing for the HomeScreen follows:

class HomeScreen extends StatefulWidget {
  final FormRepository formRepository;

  const HomeScreen({
    super.key,
    required this.formRepository,
  });

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  final GlobalKey<FormBuilderState> _formKey = GlobalKey<FormBuilderState>();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Dynamic Forms"),
      ),
      body: FutureBuilder<DynamicForm?>(
        future: widget.formRepository.loadForm(),
        builder: (BuildContext context, AsyncSnapshot<DynamicForm?> snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.center,
                children: [
                  CircularProgressIndicator(),
                  SizedBox(height: 16),
                  Text("Loading form, please wait..."),
                ],
              ),
            );
          }
          if (snapshot.hasError) {
            return Center(
              child: Text("Something bad happened ${snapshot.error}"),
            );
          }
          if (snapshot.hasData) {
            return Padding(
              padding: const EdgeInsets.all(16.0),
              child: FormBuilder(
                key: _formKey,
                child: Column(
                  children: snapshot.data!.fields
                      .map(
                        (field) => DynamicFormInput(
                          field: field,
                        ),
                      )
                      .toList(),
                ),
              ),
            );
          }
          return const Center(child: Text("Form does not exist."));
        },
      ),
      floatingActionButton: FloatingActionButton(
        mini: true,
        onPressed: () {
          if (_formKey.currentState!.saveAndValidate(focusOnInvalid: false)) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(
                backgroundColor: Colors.green,
                duration: const Duration(seconds: 10),
                content: Text(
                  'Form successfully validated and saved. Form data: ${_formKey.currentState!.value}',
                ),
              ),
            );
          } else {
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(
                backgroundColor: Colors.redAccent,
                content: Text('Form validation failed'),
              ),
            );
          }
        },
        child: const Icon(Icons.save),
      ),
    );
  }
}

Nothing too extreme in the code above, let's focus on the part where the data has finished loading and we have a data snapshot to work with. Note, that we're using the FormBuilder widget from the flutter_form_builder package and setting its child to a Column widget.

Now, the real action starts when we populate the Column widget's children by mapping the fields properties of the DynamicForm class to a DynamicFormInput widget.

The DynamicFormInput widget

The DynamicFormInput widget accepts a DynamicFormField object as a constructor parameter. Based on the type property of the object, we then return a different type of form element from the flutter_form_builder package. The code is relatively straightforward, but the real beauty of the flutter_form_builder is shown when we get to the validator property of its widgets. You'll notice how we check the validations property of the DynamicFormField object and then add the necessary FormBuilderValidators as needed.

The code listing for the DynamicFormInput widget follows:

class DynamicFormInput extends StatelessWidget {
  final DynamicFormField field;

  const DynamicFormInput({
    super.key,
    required this.field,
  });

  @override
  Widget build(BuildContext context) {
    switch (field.type) {
      case 'TextBox':
        return Padding(
          padding: const EdgeInsets.only(bottom: 16.0),
          child: FormBuilderTextField(
            autovalidateMode: AutovalidateMode.onUserInteraction,
            name: field.key,
            decoration: InputDecoration(labelText: field.label),
            keyboardType: _getInputType(
              field.validations.numeric,
              field.validations.email,
            ),
            validator: FormBuilderValidators.compose(
              [
                if (field.validations.required)
                  FormBuilderValidators.required(errorText: 'Required'),
                if (field.validations.email != null && field.validations.email == true)
                  FormBuilderValidators.email(errorText: "A valid email is required"),
                if (field.validations.numeric != null && field.validations.numeric == true)
                  FormBuilderValidators.numeric(errorText: "A number is required"),
                if (field.validations.minLength != null)
                  FormBuilderValidators.minLength(field.validations.minLength!),
                if (field.validations.maxLength != null)
                  FormBuilderValidators.maxLength(field.validations.maxLength!),
              ],
            ),
          ),
        );

      case 'DropdownList':
        return FormBuilderDropdown(
          autovalidateMode: AutovalidateMode.onUserInteraction,
          name: field.key,
          decoration: InputDecoration(labelText: field.label),
          items: field.options!
              .map<DropdownMenuItem<String>>(
                (String value) => DropdownMenuItem(
                  value: value,
                  child: Text(value),
                ),
              )
              .toList(),
          validator: FormBuilderValidators.compose([
            if (field.validations.required)
              FormBuilderValidators.required(errorText: 'Please make a selection'),
          ]),
        );

      default:
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: Text(
            "Field Type [${field.type}] does not exist.",
            style: const TextStyle(
              color: Colors.redAccent,
              fontWeight: FontWeight.bold,
            ),
          ),
        );
    }
  }

  TextInputType _getInputType(bool? numeric, bool? email) {
    if (numeric != null && numeric == true) {
      return TextInputType.number;
    }
    if (email != null && email == true) {
      return TextInputType.emailAddress;
    }
    return TextInputType.text;
  }
}

The DynamicForm class

We're using the DynamicForm, DynamicFormField and DynamicFormFieldValidation classes to give us a strongly-typed way to access the form data we receive from the FormRepository. There is nothing very special about these classes, I used the json_serializable package to help with the serialization/deserialization. If that's not your cup of tea, check the other branch on the sample GitHub repo, for an example without using it.

The code for the classes follows:

import 'package:json_annotation/json_annotation.dart';

part 'dynamic_form.g.dart';

@JsonSerializable()
class DynamicForm {
  final int id;
  final String name;
  final List<DynamicFormField> fields;

  DynamicForm({
    required this.id,
    required this.name,
    required this.fields,
  });

  factory DynamicForm.fromJson(Map<String, dynamic> json) => _$DynamicFormFromJson(json);
  Map<String, dynamic> toJson() => _$DynamicFormToJson(this);
}
import 'package:json_annotation/json_annotation.dart';

import '../domain_models.dart';

part 'dynamic_form_field.g.dart';

@JsonSerializable()
class DynamicFormField {
  final String type;
  final String label;
  final String key;
  final List<String>? options;
  final DynamicFormFieldValidation validations;

  DynamicFormField({
    required this.type,
    required this.label,
    required this.key,
    required this.options,
    required this.validations,
  });

  factory DynamicFormField.fromJson(Map<String, dynamic> json) => _$DynamicFormFieldFromJson(json);

  Map<String, dynamic> toJson() => _$DynamicFormFieldToJson(this);
}
import 'package:json_annotation/json_annotation.dart';

part 'dynamic_form_field_validation.g.dart';

@JsonSerializable()
class DynamicFormFieldValidation {
  final bool required;
  final int? minLength;
  final int? maxLength;
  final bool? email;
  final bool? numeric;

  DynamicFormFieldValidation({
    required this.required,
    required this.minLength,
    required this.maxLength,
    required this.email,
    required this.numeric,
  });

  factory DynamicFormFieldValidation.fromJson(Map<String, dynamic> json) => _$DynamicFormFieldValidationFromJson(json);
  Map<String, dynamic> toJson() => _$DynamicFormFieldValidationToJson(this);
}

The FormRepository

You'll notice that the HomeScreen's FutureBuilder's future property is set to:

widget.formRepository.loadForm()

The FormRepository's loadForm method simulates loading the data from an API with a short delay and simply parses a JSON string and deserializes the JSON into a DynamicForm class and returns said object.

The full code for the FormRepository follows:

class FormRepository {

  String getJson() {
    String json = '''{
  "id": 1,
  "name": "Contact Details Form",
  "fields": [
    {
      "type": "TextBox",
      "label": "Full Name",
      "key": "fullName",
      "validations": {
        "required": true,
        "minLength": 3,
        "maxLength": 50
      }
    },
    {
      "type": "TextBox",
      "label": "Email",
      "key": "email",
      "validations": {
        "required": true,
        "email": true
      }
    },
    {
      "type": "TextBox",
      "label": "Age",
      "key": "age",
      "validations": {
        "required": false,
        "numeric": true
      }
    },
    {
      "type": "DropdownList",
      "label": "Province",
      "key": "province",
      "options": [
        "Eastern Cape",
        "Free State",
        "Gauteng",
        "KwaZulu-Natal",
        "Limpopo",
        "Mpumalanga",
        "Northern Cape",
        "North West",
        "Western Cape"
      ],
      "validations": {"required": true}
    }
  ]
}''';
    return json;
  }

  Future<DynamicForm> loadForm() async {
    // Simulate loading Time as if calling an API
    await Future.delayed(const Duration(seconds: 3));
    final dynamicForm = DynamicForm.fromJson(jsonDecode(getJson()));
    return dynamicForm;
  }
}

That is basically it when it comes to this solution, there is a lot of room for improvement and enhancement, but I think this should be a pretty good starting point. Here is a short clip of how the forms validates:

The full code for this example is available on GitHub

Thank you for reading. Until next time, keep coding!