Flutter Forms — Input Fields, Validation, and Submission
In this tutorial, you will learn about Flutter Forms. We cover key concepts, practical examples, and best practices to help you master this topic.
Flutter forms handle user input with TextFormField widgets, Form validation, and submission callbacks, enabling structured data collection with built-in validation and error handling.
What Will You Learn
- Building forms with Form and TextFormField
- Input validation with validators
- Form submission and error handling
- Input formatting and masks
- Dropdown, date picker, and switch inputs
- Custom form field creation
- Managing form state
Why It Matters
Forms are the primary way users enter data in mobile apps. Flutter's Form widget provides a structured approach with built-in validation, focus management, and keyboard handling. Proper form implementation affects user experience, data quality, and Accessibility. Understanding form validation patterns, input formatting, and error display ensures a professional user experience.
Real-World Use
The DodaTech Flutter app uses forms for user registration, profile editing, and checkout. Registration has email validation (format check + uniqueness), password strength indicator, and terms acceptance. The profile form uses a date picker for birth date, dropdown for country selection, and formatted phone input. All forms use GlobalKey<FormState> for validation and submission control.
Learning Path
flowchart LR A[Flutter Navigation] --> B[Flutter Forms\nYou are here] B --> C[Flutter Networking] style B fill:#f90,color:#fff
Basic Form Setup
The Form widget wraps form fields and manages validation and submission:
import 'package:flutter/material.dart';
class RegistrationForm extends StatefulWidget {
const RegistrationForm({super.key});
@override
State<RegistrationForm> createState() => _RegistrationFormState();
}
class _RegistrationFormState extends State<RegistrationForm> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Registration successful!')),
);
// Process form data
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Register')),
body: Padding(
padding: EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: 'Full Name',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter your name';
}
return null;
},
),
SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) return 'Email is required';
if (!value.contains('@') || !value.contains('.')) {
return 'Enter a valid email';
}
return null;
},
),
SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock),
),
obscureText: true,
validator: (value) {
if (value == null || value.length < 8) {
return 'Password must be at least 8 characters';
}
return null;
},
),
SizedBox(height: 24),
ElevatedButton(
onPressed: _submitForm,
child: Text('Register'),
),
],
),
),
),
);
}
}
GlobalKey<FormState> identifies the form. validate() runs all field validators. The validator function returns null for valid or a String error message for invalid. Each TextFormField can have its own validator.
Input Validation Techniques
Dart provides multiple validation approaches:
class ValidationExamples {
// Basic required field
static String? required(String? value, String fieldName) {
if (value == null || value.trim().isEmpty) {
return '$fieldName is required';
}
return null;
}
// Email format
static String? email(String? value) {
if (value == null || value.isEmpty) return null; // Optional field
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(value)) {
return 'Enter a valid email address';
}
return null;
}
// Min length
static String? minLength(String? value, int min, String fieldName) {
if (value != null && value.length < min) {
return '$fieldName must be at least $min characters';
}
return null;
}
// Numeric range
static String? range(String? value, num min, num max, String fieldName) {
if (value == null || value.isEmpty) return null;
final numVal = num.tryParse(value);
if (numVal == null) return 'Enter a valid number';
if (numVal < min || numVal > max) {
return '$fieldName must be between $min and $max';
}
return null;
}
// Must match another field
static String? matches(String? value, String compareValue, String fieldName) {
if (value != compareValue) {
return '$fieldName does not match';
}
return null;
}
}
Extract validation logic into reusable functions or a validation mixin. This keeps widgets clean and enables Unit Testing of validation rules.
Input Formatting
Format text input as the user types:
import 'package:flutter/services.dart';
class FormattedInputs extends StatelessWidget {
const FormattedInputs({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
// Phone number formatting
TextFormField(
decoration: InputDecoration(
labelText: 'Phone',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone),
),
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
_PhoneNumberFormatter(),
],
),
SizedBox(height: 16),
// Uppercase input
TextFormField(
decoration: InputDecoration(
labelText: 'Coupon Code',
border: OutlineInputBorder(),
),
textCapitalization: TextCapitalization.characters,
inputFormatters: [
UpperCaseTextFormatter(),
],
),
SizedBox(height: 16),
// Numeric input with decimal
TextFormField(
decoration: InputDecoration(
labelText: 'Price',
border: OutlineInputBorder(),
prefixText: '\$ ',
),
keyboardType: TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')),
],
),
],
);
}
}
class _PhoneNumberFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final text = newValue.text.replaceAll(RegExp(r'\D'), '');
var formatted = '';
for (var i = 0; i < text.length; i++) {
if (i == 3 || i == 6) formatted += '-';
formatted += text[i];
}
return newValue.copyWith(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
}
class UpperCaseTextFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
return newValue.copyWith(text: newValue.text.toUpperCase());
}
}
TextInputFormatter intercepts text changes and can modify the input. This is useful for phone numbers, credit cards, uppercase codes, and numeric formatting.
Dropdown and Selection Inputs
Use DropdownButtonFormField for selection from a list:
class DropdownExample extends StatefulWidget {
const DropdownExample({super.key});
@override
State<DropdownExample> createState() => _DropdownExampleState();
}
class _DropdownExampleState extends State<DropdownExample> {
String? _selectedCountry;
DateTime? _selectedDate;
bool _agreeToTerms = false;
final _countries = [
'United States',
'Canada',
'United Kingdom',
'Germany',
'France',
'Japan',
'Australia',
];
@override
Widget build(BuildContext context) {
return Form(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Dropdown
DropdownButtonFormField<String>(
value: _selectedCountry,
decoration: InputDecoration(
labelText: 'Country',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.map),
),
items: _countries.map((country) {
return DropdownMenuItem(
value: country,
child: Text(country),
);
}).toList(),
onChanged: (value) {
setState(() => _selectedCountry = value);
},
validator: (value) {
if (value == null) return 'Please select a country';
return null;
},
),
SizedBox(height: 16),
// Date picker
TextFormField(
readOnly: true,
decoration: InputDecoration(
labelText: 'Birth Date',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.calendar_today),
suffixIcon: Icon(Icons.arrow_drop_down),
),
controller: TextEditingController(
text: _selectedDate != null
? '${_selectedDate!.year}-${_selectedDate!.month.toString().padLeft(2, '0')}-${_selectedDate!.day.toString().padLeft(2, '0')}'
: '',
),
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(1900),
lastDate: DateTime.now(),
);
if (date != null) {
setState(() => _selectedDate = date);
}
},
),
SizedBox(height: 16),
// Switch
SwitchListTile(
title: Text('Agree to Terms'),
value: _agreeToTerms,
onChanged: (value) {
setState(() => _agreeToTerms = value);
},
),
SizedBox(height: 24),
ElevatedButton(
onPressed: () {},
child: Text('Submit'),
),
],
),
),
);
}
}
DropdownButtonFormField integrates with Form validation. The date picker opens in a bottom sheet or dialog. SwitchListTile provides a labeled toggle.
Autocomplete
Flutter provides Autocomplete for suggestions:
class AutocompleteExample extends StatelessWidget {
const AutocompleteExample({super.key});
static const _suggestions = [
'Apple', 'Banana', 'Cherry', 'Date', 'Elderberry',
'Fig', 'Grape', 'Honeydew', 'Kiwi', 'Lemon',
'Mango', 'Nectarine', 'Orange', 'Papaya', 'Quince',
];
@override
Widget build(BuildContext context) {
return Autocomplete<String>(
optionsBuilder: (textEditingValue) {
if (textEditingValue.text.isEmpty) return [];
return _suggestions.where((suggestion) {
return suggestion.toLowerCase().contains(
textEditingValue.text.toLowerCase(),
);
});
},
onSelected: (selection) {
print('Selected: $selection');
},
fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
return TextFormField(
controller: controller,
focusNode: focusNode,
decoration: InputDecoration(
labelText: 'Search fruit',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.search),
),
);
},
);
}
}
Autocomplete shows suggestions as the user types. The optionsBuilder returns matching items. Use fieldViewBuilder for custom styling.
Focus Management
Manage focus between fields for better keyboard UX:
class FocusExample extends StatefulWidget {
const FocusExample({super.key});
@override
State<FocusExample> createState() => _FocusExampleState();
}
class _FocusExampleState extends State<FocusExample> {
final _field1Focus = FocusNode();
final _field2Focus = FocusNode();
final _field3Focus = FocusNode();
@override
void dispose() {
_field1Focus.dispose();
_field2Focus.dispose();
_field3Focus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Form(
child: Column(
children: [
TextFormField(
focusNode: _field1Focus,
decoration: InputDecoration(labelText: 'Field 1'),
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _field2Focus.requestFocus(),
),
TextFormField(
focusNode: _field2Focus,
decoration: InputDecoration(labelText: 'Field 2'),
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _field3Focus.requestFocus(),
),
TextFormField(
focusNode: _field3Focus,
decoration: InputDecoration(labelText: 'Field 3'),
textInputAction: TextInputAction.done,
),
],
),
);
}
}
Set textInputAction: TextInputAction.next to show a "Next" button on the keyboard. Use onFieldSubmitted to move focus to the next field. The last field uses TextInputAction.done to show a "Done" button.
Common Mistakes
Not disposing TextEditingController: Controllers must be disposed to prevent memory leaks. Always dispose in
State.dispose().Using GlobalKey
for each field : Use one Form key for the entire form. Individual fields use their own validators and controllers.Not preventing form submission while processing: Disable the submit button or show a loading indicator while the form is being submitted to prevent duplicate submissions.
Ignoring keyboard type for input fields: Set the appropriate
keyboardType(email, phone, number, url) to show the correct keyboard for each field type.Not handling autovalidate mode: Use
autovalidateMode: AutovalidateMode.onUserInteractionto show validation errors after the user interacts with a field, not before.
Practice Questions
- How does Form.validate() work with individual field validators?
- What is the purpose of TextInputFormatter?
- How does FocusNode help manage keyboard navigation between fields?
- When would you use AutovalidateMode?
- Challenge: Build a checkout form with fields for name, email, shipping address, credit card number (formatted with spaces every 4 digits), expiration date (MM/YY), and CVV. Implement validation for all fields and format the credit card number as the user types.
Mini Project
Build a complex registration form:
- Personal details section: name, email, phone (formatted), birth date (date picker)
- Address section: street, city, state (dropdown), zip code
- Account section: username, password (with strength indicator), confirm password
- Preferences section: newsletter toggle, theme selection (radio buttons)
- All fields validated with reusable validators
- Form data collected and printed as JSON on submission
- Keyboard navigation between fields
FAQ
What is Next
Now that you understand forms, learn about networking in Flutter. Proceed to Flutter Networking for HTTP requests, REST APIs, and JSON Serialization. Then explore Flutter Theming for app design and branding.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro