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: [],
),
),
);
}
}
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(),
);
}
}
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);
}
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,
),
),
);
}
}
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,
)
],
),
),
);
}
}
You can see the data being prefilled in the images below.
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),
),
),
);
}
}
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"),
)
],
),
(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),
),
),
);
}
}
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';
}
},
),
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.
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),
),
),
);
}
}
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),
),
)
],
);
});
}
}
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)