DEV Community

Cover image for Advanced form handling with vanilla Flutter
Chinmay Kabi
Chinmay Kabi

Posted on

Advanced form handling with vanilla Flutter

Disclaimer: This article goes about implementing features in a highly opinionated manner, which may not be in line with many of the tutorials out there. So have an open mind and read along! Cheerio :)

One of the things every front-end developer has to face, regardless of their product type, is to handle form inputs. Designing a form and handling its inputs have never been an exciting prospect for a developer, especially when they have to handle the UI, validation, data on submission, input formatting all at once. Luckily there are some tricks in Flutter we can use to make handling form fills a little
bit easier. This article will cover 4 broad aspects of forms

  • Prefilling/Storing data
  • Input formatting
  • Validation
  • Custom form field

For this tutorial we will be making a simple form to create a new profile. This will include text fields to enter name, mobile number, postal code and upload an image. The behavior for each field will be as follows

Text field Data type Input format Validation
Name String Alphabets only Name != empty
Mobile no String Numbers only Length ==10, only numbers
Profile Image File Image(jpg, png) File !=empty

Setting up the project

Let’s create a new flutter proiect and then clean up the main.dart file to the following code below.

import 'package:flutter/material.dart';
void main() {
  runApp(const MyApp());
}
class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    // ignore: prefer_const_constructors
    return MaterialApp(
      title: 'Better Flutter Form Demo',
      home: FormPage(),
    );
  }
}
class FormPage extends StatelessWidget {
  FormPage({Key? key}) : super(key: key);
  final _formKey = GlobalKey<FormState>();
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Better Flutter Forms'),
      ),
      body: Form(
        key: _formKey,
        child: Column(
          children: [],
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

It is always a good idea to make the widgets as modular as possible, so we will be making a custom widget which will handle all our text input, a CustomTextField. Create a file custom_text_field.dart and add the following code.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CustomTextField extends StatefulWidget {
  CustomTextField(
      {Key? key})
      : super(key: key);
  @override
  State<CustomTextField> createState() => _CustomTextFieldState();
}
class _CustomTextFieldState extends State<CustomTextField> {
  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: TextFormField(),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

We will go on adding whatever properties we require to our custom widget and use it in the form.

Prefilling/Storing data

Prefilling text in a textfield or getting current data of a textfield is usually done using a TextEditingController. But this does not scale well when dealing with multiple controllers in a form. A much easier way to deal with flow of data is to use a model class object to handle everything. Let us create a class for our data in this form.

import 'dart:io';
class Profile {
  Profile(
      {required this.name, required this.phoneNo, required this.profilePic});
  String? name;
  String? phoneNo;
  File? profilePic;
  Profile copyWith({String? name, String? phoneNo, File? profilePic}) =>
      Profile(
          name: name ?? this.name,
          phoneNo: phoneNo ?? this.phoneNo,
          profilePic: profilePic ?? this.profilePic);
}
Enter fullscreen mode Exit fullscreen mode

TextFormfield has a built-in property called initialValue which when not null prefills the text field with the data passed. It also has a callback called onChange which is fired everytime user inputs something in the text field. To expose these functioality from our CustomTextField, we add the following properties to it.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CustomTextField extends StatefulWidget {
  CustomTextField(
      {Key? key,
      required this.hintText,
      required this.initialValue,
      required this.onChanged,})
      : super(key: key);
  final String? initialValue;
  final Function(String) onChanged;
  final String hintText;
  @override
  State<CustomTextField> createState() => _CustomTextFieldState();
}
class _CustomTextFieldState extends State<CustomTextField> {
  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: TextFormField(
        initialValue: widget.initialValue,
        onChanged: widget.onChanged,
        decoration: InputDecoration(
          hintText: widget.hintText,
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

We add the name and phone no fields to our form.

class FormPage extends StatelessWidget {
  FormPage({Key? key, Profile? profile})
      : _profile = profile ?? Profile(),
        super(key: key);
  final _formKey = GlobalKey<FormState>();
  Profile _profile;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Better Flutter Forms'),
      ),
      body: Form(
        key: _formKey,
        child: Column(
          children: [
            CustomTextField(
              hintText: 'Name',
              initialValue: _profile.name,
              onChanged: (value) => _profile.name = value,
            ),
            CustomTextField(
              hintText: 'Phone No',
              initialValue: _profile.phoneNo,
              onChanged: (value) => _profile.phoneNo = value,
            )
          ],
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

You can see the data being prefilled in the images below.

All fields filled up

None of the fields filled up

Available data is prefilled

As seen in the example, the text field will now prefill form data depending on whether it has been passed the data or not, doesn’t matter how many fields there are! Had we used controller for each text field, we would have to take care of each prefill, which does not scale properly. Similarly all the data changes are neatly stored in _profile .

Input formatting

Taking in improper inputs from a form and processing it can cause a lot of problems. While they can always be checked for before submission, a better UX would be to let the user not make that mistake while entering itself. We use a property called inputFormatters for that. It allows or rejects user input depending on the RegEx pattern we pass it. Another parameter that will come handy is an input length. Let us add these two properties to our CustomTextField. Also while we are at it, let’s add some decoration to make the UI look decent!

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CustomTextField extends StatefulWidget {
  CustomTextField(
      {Key? key,
      required this.hintText,
      required this.initialValue,
      required this.onChanged,
      this.inputFormatters,
      this.maxLength})
      : super(key: key);
  final String? initialValue;
  final Function(String) onChanged;
  final String hintText;
  final List<TextInputFormatter>? inputFormatters;
  final int? maxLength;

  @override
  State<CustomTextField> createState() => _CustomTextFieldState();
}
class _CustomTextFieldState extends State<CustomTextField> {
  final _borderStyle = OutlineInputBorder(
      borderSide: const BorderSide(
        color: Color(0xfffafafa),
      ),
      borderRadius: BorderRadius.circular(8));

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: TextFormField(
        initialValue: widget.initialValue,
        onChanged: widget.onChanged,
        maxLength: widget.maxLength,
        inputFormatters: widget.inputFormatters,
        validator: widget.validator,
        decoration: InputDecoration(
          fillColor: Colors.grey.withOpacity(0.1),
          filled: true,
          enabledBorder: _borderStyle,
          focusedBorder: _borderStyle,
          hintText: widget.hintText,
          contentPadding:
              const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

In case of name, we only want alphabets and space to be allowed. For this we will be using a predefined input formatters called FilteringTextInputFormatter. This has two methods which take in a RegExp as an input, allow() and deny() , which can used depending on your filtering strategy. Here we want to allow only alphabets and spaces, and the RegExp for that is
[a-zA-Z]+|\s . So we will implement this by passing the following to our name field.

CustomTextField(
              hintText: 'Name',
              initialValue: _profile.name,
              onChanged: (value) => _profile.name = value,
              inputFormatters: [
                FilteringTextInputFormatter.allow(
                  RegExp(r"[a-zA-Z]+|\s"),
                )
              ],
            ),
Enter fullscreen mode Exit fullscreen mode

(Note : Writing a string like this, r"some string" means that we are declaring it as a raw string, meaning that they will be treated as the character they are visible. No string interpolations will work inside a raw string and no characters which usually need to be escaped will require escaping anymore, which makes it useful while working with RegExp patterns).

Now when we try typing in our name field, the only input accepted are alphabets and space!

The same can be applied for number field by using an inbuilt method called FilteringTextInputFormatter.digitsOnly. Also we need to fix the input length in phone field, so we pass a maxLength of 10.

Validators

These are callbacks fired when a form is validated. These functions receive the latest values in the field, and if any string is returned from these functions, then it is understood that the input in that particular field is invalid, and the string returned is the error text. In our custom widget, we implement a validator. After adding a validator callback, our CustomTextField widget looks like this

class CustomTextField extends StatefulWidget {
  CustomTextField(
      {Key? key,
      required this.hintText,
      required this.initialValue,
      required this.onChanged,
      this.inputFormatters,
      this.validator,
      this.maxLength})
      : super(key: key);
  final String? initialValue;
  final Function(String) onChanged;
  final String hintText;
  final List<TextInputFormatter>? inputFormatters;
  final int? maxLength;
  final String? Function(String?)? validator;
  @override
  State<CustomTextField> createState() => _CustomTextFieldState();
}
class _CustomTextFieldState extends State<CustomTextField> {
  final _focusNode = FocusNode();
  final _borderStyle = OutlineInputBorder(
      borderSide: const BorderSide(
        color: Color(0xfffafafa),
      ),
      borderRadius: BorderRadius.circular(8));

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: TextFormField(
        initialValue: widget.initialValue,
        onChanged: widget.onChanged,
        maxLength: widget.maxLength,
        inputFormatters: widget.inputFormatters,
        validator: widget.validator,
        decoration: InputDecoration(
          fillColor: Colors.grey.withOpacity(0.1),
          filled: true,
          enabledBorder: _borderStyle,
          focusedBorder: _borderStyle,
          hintText: widget.hintText,
          contentPadding:
              const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

In the name field, we can check for validity in the following manner

CustomTextField(
              hintText: 'Name',
              initialValue: _profile.name,
              onChanged: (value) => _profile.name = value,
              inputFormatters: [
                FilteringTextInputFormatter.allow(
                  RegExp(r"[a-zA-Z]+|\s"),
                )
              ],
              validator: (val) {
                if (val == null || val.isEmpty) {
                  return 'Enter a valid String';
                }
              },
            ),
Enter fullscreen mode Exit fullscreen mode

When no error message is returned from the validator function, it means the input is valid.

While validation is working fine here, but here we have to press the submit button for validation to kick in. What if a user has not typed their full phone number and moved to type their name, the error message should pop up right then to complete the number, that would be good UX too! For that we have a property called autoValidateMode in the TextFormField. Setting it to autovalidateMode: AutovalidateMode.onUserInteraction means that validation for a field will kick in the moment user changes their focus to any other field.

Autovalidate mode in action

Something’s wrong here, right? Validation fires up on user interaction, just how its name suggests, but we want it to fire once user focuses on another field. For that we will be using a FocusNode. We declare a FocusNode object inside the CustomTextField and assign it to the TextFormField. Now all we have to do is disable the validator when the field is in focus. We also will have to call setState() when focus changes, otherwise the error message won’t be shown on focus change by itself.

class CustomTextField extends StatefulWidget {
  CustomTextField(
      {Key? key,
      required this.hintText,
      required this.initialValue,
      required this.onChanged,
      this.inputFormatters,
      this.validator,
      this.maxLength})
      : super(key: key);
  final String? initialValue;
  final Function(String) onChanged;
  final String hintText;
  final List<TextInputFormatter>? inputFormatters;
  final int? maxLength;
  final String? Function(String?)? validator;
  @override
  State<CustomTextField> createState() => _CustomTextFieldState();
}
class _CustomTextFieldState extends State<CustomTextField> {
  final _focusNode = FocusNode();
  final _borderStyle = OutlineInputBorder(
      borderSide: const BorderSide(
        color: Color(0xfffafafa),
      ),
      borderRadius: BorderRadius.circular(8));
  @override
  void initState() {
    super.initState();
    _focusNode.addListener(() {
      if (!_focusNode.hasFocus) setState(() {});
    });
  }
  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: TextFormField(
        focusNode: _focusNode,
        initialValue: widget.initialValue,
        onChanged: widget.onChanged,
        maxLength: widget.maxLength,
        inputFormatters: widget.inputFormatters,
        validator: (value) {
          if (_focusNode.hasFocus) {
            return null;
          } else {
            return widget.validator?.call(value);
          }
        },
        autovalidateMode: AutovalidateMode.onUserInteraction,
        decoration: InputDecoration(
          fillColor: Colors.grey.withOpacity(0.1),
          filled: true,
          enabledBorder: _borderStyle,
          focusedBorder: _borderStyle,
          hintText: widget.hintText,
          contentPadding:
              const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode


Now that looks like polished UX, with just a few lines of code! This behavior will be default for all CustomTextField widgets.

Custom Form field

What makes a form widget differentiate from other widget is the data validation part. Usually developers may tend to take the easy route and validate any custom field separately, for eg - an image upload field, but that would not scale in case of a complex form. We will be making our own CustomImageFormField! Create a custom_image_form_field.dart and add the following code.

import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
class CustomImageFormField extends StatelessWidget {
  CustomImageFormField({
    Key? key,
    required this.initialValue,
    required this.validator,
    required this.onPicked,
  }) : super(key: key);
  final File? initialValue;
  final String? Function(File?) validator;
  final Function(File) onPicked;
  File? _pickedFile;
  @override
  Widget build(BuildContext context) {
    return FormField(
        initialValue: initialValue,
        validator: (_) => validator.call(_pickedFile),
        builder: (formFieldState) {
          return Column(
            children: [
              GestureDetector(
                onTap: () async {
                  FilePickerResult? file = await FilePicker.platform
                      .pickFiles(type: FileType.image, allowMultiple: false);
                  if (file != null) {
                    _pickedFile = File(file.files.first.path!);
                    onPicked.call(_pickedFile!);
                  }
                },
                child: Container(
                  margin: const EdgeInsets.all(8),
                  padding:
                      const EdgeInsets.symmetric(horizontal: 32, vertical: 8),
                  decoration: BoxDecoration(
                    borderRadius: BorderRadius.circular(8),
                    color: const Color(0xff707070).withOpacity(0.1),
                  ),
                  child: Column(
                    children: const [
                      Icon(Icons.upload_file),
                      Text('Upload profile picture')
                    ],
                  ),
                ),
              ),
              if (formFieldState.hasError)
                Padding(
                  padding: const EdgeInsets.only(left: 8, top: 10),
                  child: Text(
                    formFieldState.errorText!,
                    style: TextStyle(
                        fontStyle: FontStyle.normal,
                        fontSize: 13,
                        color: Colors.red[700],
                        height: 0.5),
                  ),
                )
            ],
          );
        });
  }
}
Enter fullscreen mode Exit fullscreen mode

To make our own form field, we can design the UI in any way we want and justwrap it in a FormField. It has all the usual properties like validator and initial value. To pick up image, file_picker package was used. Depending on whether this field has an error or not, a widget is conditionally shown. Now when we make a bad submission, we get this kind of error.

FormField widget can be used to make form fields for a variety of uncommon inputs like file picker, date picker, radio buttons etc.
If we pass the Profile object in which we have saved all data to another screen and display the data, we see the following.

In this tutorial we learnt about how to handle prefilling and saving data, formatting our inputs, validating with a good UX flow, and making our own form field, all without using any third party form handling packages. All these things, when coupled together are enough to handle most of the forms.

Top comments (0)