Creating Custom Form Fields in Flutter
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 avalidator
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 thesave
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 callvalidator
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 isint
, 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 likesave
,validate
, etc. - Our counter exposes the same parameters as its super class, except the
builder
parameter. It also sets theinitialValue
to 0, and initializesautovalidate
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 theCounter
widget. However, instead of callingsetState
, 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 thebuilder
. This instance is the one that maintains the actual value of the field. That is why, in theText
widget displayed in the middle of the field, we are usingstate.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 callingsetState
.
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
totrue
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.
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 aColumn
. - This column has two children, the row that contains our original widget, and a child which is either a
Text
that contains thestate.errorText
, or an emptyContainer
, depending on the value ofstate.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!