# 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:

```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}
    }
  ]
}
```

### 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](https://pub.dev/packages/flutter_form_builder) package as well as its companion [form\_builder\_validators](https://pub.dev/packages/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:

```dart
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:

```dart
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](https://pub.dev/packages/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](https://github.com/Pietervdw/flutter-dynamic-forms), for an example without using it.

The code for the classes follows:

```dart
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);
}
```

```dart
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);
}
```

```dart
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:

```dart
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:

```dart
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:

%[https://www.youtube.com/watch?v=ODcUuSoi33I] 

The full code for this example is available on [GitHub](https://github.com/Pietervdw/flutter-dynamic-forms)

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