Creating Custom Form Fields in Flutter

AbdulRahman AlHamali
SAUGO 360
Published in
7 min readAug 16, 2018

When you’re first introduced to Flutter, you learn about creating forms, validating them, and submitting them. Your forms can contain text fields, radio buttons, etc. But what if you want to move one extra step, and create your own custom form fields that you can validate, manipulate and submit like any other form field. This tutorial is just about that!

We will build a CounterFormField that, like any other form field, has a validator callback, an onSubmit callback, and can display an error to the user when invalid.

A simple counter

Let’s start with the following app:

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: HomeScreen()
);
}
}

class HomeScreen extends StatefulWidget {
@override
_HomeScreenState createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {

final _formKey = GlobalKey<FormState>();

String _name;
int _age;

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Form(
key: this._formKey,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text('Please fill in your name and age'),
TextFormField(
autovalidate: false,
onSaved: (value) => this._name = value,
validator: (value) {
if (value.length < 3) {
return
'a minimum of 3 characters is required'
;
}
},
),
Counter(),
RaisedButton(
child: Text('Submit'),
onPressed: () {
if (this._formKey.currentState.validate()) {
setState(() {
this._formKey.currentState.save();
});
}
},
),
SizedBox(height: 50.0),
Text(
'${this._name} is ${this._age} years old'
)
],
),
),
),
),
);
}
}
  • We create a simple scaffold. In the center of this scaffold we place a form where we ask the user to fill in his/her name and age
  • To fill the name, we present the user with a TextFormField. The field has a validator that checks whether the name is longer than three characters, and displays an error otherwise. The field also stores its value in a member variable called _name when the form is submitted (saved)
  • The form also has a widget called Counter. We will look at this widget in a bit
  • In addition, we have a RaisedButton which, when pressed, checks to see if the form is valid. If so, it saves that form
  • Finally, we display the name and age of the user in a Text widget.

Note: if this is not familiar to you, kindly review the form tutorials before proceeding

Now, our Counter is a very simple widget:

class Counter extends StatefulWidget {

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

class _CounterState extends State<Counter> {

int value;

@override
void initState() {
super.initState();
this.value = 0;
}

@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
IconButton(
icon: Icon(Icons.remove),
onPressed: () {
setState(() {
this.value--;
});
},
),
Text(
this.value.toString()
),
IconButton(
icon: Icon(Icons.add),
onPressed: () {
setState(() {
this.value++;
});
},
),
],
);
}
}
  • It is a stateful widget, that maintains a value, initializes it to zero, and provides two buttons to increment/decrement this value

The problem with this counter is that we cannot use its value in our form. We can modify its code the traditional way, and make it emit values to its parent whenever it changes, and then maintain its value in the parent. But what is better, is to be able to validate it and save it along with the other form controls, in a clean and maintainable way.

So..

The FormField class

To implement form fields, Flutter provides us with the FormField class, which all form fields extend. This class is a simple widget, that has the following properties:

  • initialValue: The initial value to display in the field.
  • builder: A callback that is responsible for building the widget inside the form field.
  • onSaved: A callback that is called whenever we submit the field (usually by calling the save method on a form.)
  • validator: A callback that is called to know whether the field currently has a valid value.
  • autovalidate: A boolean that specifies whether we want to call validator every time the field changes, or only when the field is submitted.

Building the CounterFormField

Thus, we can build our CounterFormField to extend FormField:

class CounterFormField extends FormField<int> {

CounterFormField({
FormFieldSetter<int> onSaved,
FormFieldValidator<int> validator,
int initialValue = 0,
bool autovalidate = false
}) : super(
onSaved: onSaved,
validator: validator,
initialValue: initialValue,
autovalidate: autovalidate,
builder: (FormFieldState<int> state) {
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
IconButton(
icon: Icon(Icons.remove),
onPressed: () {
state.didChange(state.value - 1);
},
),
Text(
state.value.toString()
),
IconButton(
icon: Icon(Icons.add),
onPressed: () {
state.didChange(state.value + 1);
},
),
],
);
}
);
}
  • Our counter extends the FormField class, and specifies that the type parameter of this field is int, because our counter will contain an integer value representing the age.
  • Because our widget extends FormField, it is automatically registered with the parent form, and will automatically have functionalities like save, validate, etc.
  • Our counter exposes the same parameters as its super class, except the builder parameter. It also sets the initialValue to 0, and initializes autovalidate to false.
  • The counter passes all its parameters as they are to the super class, and then provides a builder callback.
  • The builder callback draws our widget. It draws the same Row widget that we were drawing earlier in the Counter widget. However, instead of calling setState, and changing the value manually every time a button is pressed, we are doing something different.
  • We are using an instance of FormFieldState class provided to us by the builder. This instance is the one that maintains the actual value of the field. That is why, in the Text widget displayed in the middle of the field, we are using state.value.toString() to obtain the value of our field and display it.
  • The state instance also provides a bunch of functionalities, the most important of them is the didChange method, which we should call with the new value whenever the value of the field changes. This method updates the field’s value, informs the parent form of the change, and rebuilds the widget. That is why our increment and decrement buttons are now calling this method, instead of calling setState.

Using our form field in a real form

Now, in our form, we can replace the Counter with a CounterFormField:

CounterFormField(
autovalidate: false,
validator: (value) {
if (value < 0) {
return 'Negative values not supported';
}
},
onSaved: (value) => this._age = value,
)
  • We can set autovalidate to true if we want the field to be validated every time it changes.
  • Our validator checks whether the value is non-negative, and rejects it otherwise.
  • Our onSaved callback saves the value of the counter in the _age member.

And that’s it! Our counter is now a form field that gets validated and submitted with the rest of the form!

When we call form.validate(), the counter’s validator is called, and if it returns an error text, form.validate() fails. Also, calling form.save() will automatically call our onSaved callback which will store the value in the _age member variable.

But wait..

Nothing shows when we present an invalid value

Yes, in the TextFormField, presenting in invalid value show the error text in red below the field, but our fancy counter doesn’t do that! It does half the job, and refuses to submit the invalid value, but we want to show the error message, to let the user know what went wrong.

The value is invalid, but no error text is displayed

To do that, we will also use the state instance in the builder callback. Specifically, we will use its hasError member, which is set totrue whenever the validator callback returns anything but null, and the errorText member, which contains the error message.

builder: (FormFieldState<int> state) {
return Column(
children: <Widget>[
Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
IconButton(
icon: Icon(Icons.remove),
onPressed: () {
state.didChange(state.value - 1);
},
),
Text(
state.value.toString()
),
IconButton(
icon: Icon(Icons.add),
onPressed: () {
state.didChange(state.value + 1);
},
),
],
),
state.hasError?
Text(
state.errorText,
style: TextStyle(
color: Colors.red
),
) :
Container()
],
);
}
  • We wrapped our Row with a Column.
  • This column has two children, the row that contains our original widget, and a child which is either a Text that contains the state.errorText, or an empty Container, depending on the value of state.hasError.

And that’s it! Our form field now shows the error message whenever it is invalid!

We can set autovalidate to true to make it display the error text as soon as its value is changed, or we can keep it to false, thus only displaying the error when the form is saved.

With that, we can now create form fields that can interact with the form actions in a clean and easy fashion.

I hope this has been useful!

--

--