Input & Forms #
Input and forms are the heart of almost every app that collects user data. Flutter provides two main widgets: TextField for basic text input, and TextFormField, which integrates with the Form system for coordinated validation. Understanding both — along with the TextEditingController, FocusNode, and InputDecoration ecosystem — lets you build forms that are functional, elegant, and easy to maintain.
TextField — Basic Text Input #
TextField is the fundamental widget for receiving text input from users. It’s highly customizable and supports various keyboard types, formatters, and decorations.
TextField(
// Controller for reading/changing values programmatically
controller: _controller,
// The keyboard type that appears
keyboardType: TextInputType.emailAddress,
// The "done" button action on the keyboard
textInputAction: TextInputAction.next,
// Hide text (for passwords)
obscureText: true,
obscuringCharacter: '•',
// Maximum characters
maxLength: 100,
// Auto expansion (for textareas)
minLines: 1,
maxLines: 5,
// Visual decoration
decoration: const InputDecoration(
labelText: 'Email',
hintText: '[email protected]',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
// Callbacks
onChanged: (value) => print('Changed: $value'),
onSubmitted: (value) => _handleSubmit(value),
)
TextEditingController — Programmatic Control #
TextEditingController lets you read the text value, change field content programmatically, and listen for value changes:
class _LoginState extends State<LoginScreen> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
void initState() {
super.initState();
// Initial content (pre-fill)
_emailController.text = '[email protected]';
// Listen for changes
_emailController.addListener(() {
// Called every time the text changes
final isValid = _emailController.text.contains('@');
setState(() => _emailValid = isValid);
});
}
void _clearAll() {
_emailController.clear();
_passwordController.clear();
}
void _prefillAdmin() {
// Set text programmatically
_emailController.text = '[email protected]';
// Move the cursor to the end
_emailController.selection = TextSelection.fromPosition(
TextPosition(offset: _emailController.text.length),
);
}
String get _email => _emailController.text.trim();
String get _password => _passwordController.text;
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(controller: _emailController, ...),
TextField(controller: _passwordController, ...),
ElevatedButton(onPressed: _submit, child: const Text('Login')),
],
);
}
@override
void dispose() {
// MUST dispose to prevent memory leaks
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
}
TextField vs TextEditingController — When to Use Which? #
Use TextEditingController if:
✓ You need to read the text value when the submit button is pressed
✓ You need to change the field content programmatically (pre-fill, clear)
✓ You need to listen for value changes for custom logic
✓ You need to control the cursor position or selection
Use only the onChanged callback if:
✓ You only need to update state when the value changes
✓ You don't need to change the text programmatically
✓ Simpler code without a controller
FocusNode — Focus Management #
FocusNode enables programmatic control of keyboard focus between TextFields:
class _FormState extends State<FormScreen> {
final _nameFocus = FocusNode();
final _emailFocus = FocusNode();
final _passwordFocus = FocusNode();
@override
void initState() {
super.initState();
// Listen for focus changes
_emailFocus.addListener(() {
if (!_emailFocus.hasFocus) {
// Field lost focus -- validate
_validateEmail();
}
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
focusNode: _nameFocus,
textInputAction: TextInputAction.next,
onSubmitted: (_) {
// Move focus to the next field when Enter/Next is pressed
FocusScope.of(context).requestFocus(_emailFocus);
},
decoration: const InputDecoration(labelText: 'Name'),
),
TextField(
focusNode: _emailFocus,
textInputAction: TextInputAction.next,
onSubmitted: (_) {
FocusScope.of(context).requestFocus(_passwordFocus);
},
decoration: const InputDecoration(labelText: 'Email'),
),
TextField(
focusNode: _passwordFocus,
textInputAction: TextInputAction.done,
onSubmitted: (_) {
// Hide the keyboard and submit the form
_passwordFocus.unfocus();
_submit();
},
decoration: const InputDecoration(labelText: 'Password'),
),
],
);
}
@override
void dispose() {
_nameFocus.dispose();
_emailFocus.dispose();
_passwordFocus.dispose();
super.dispose();
}
}
Form and TextFormField — Coordinated Validation #
For forms with multiple fields and coordinated validation, use Form + TextFormField. Use a GlobalKey when creating the form. This gives a unique identity and enables form validation later.
This validation system works by flowing validation instructions from the Form widget to all child FormField widgets in a coordinated way. The validation flow can be illustrated through the following diagram:
flowchart TD
Submit["Trigger: FormState.validate()"] --> ValidateAll["Iterate All Child FormFields"]
ValidateAll --> CheckEach{"Call validator(value)"}
CheckEach -->|Returns String| ShowError["Show Error Message on Screen"]
CheckEach -->|Returns null| ValidState["Mark Input as Valid"]
ShowError --> ReturnFalse["Returns false (Validation Failed)"]
ValidState --> ReturnTrue["Returns true (Validation Succeeded)"]class RegistrationForm extends StatefulWidget {
const RegistrationForm({super.key});
@override
State<RegistrationForm> createState() => _RegistrationFormState();
}
class _RegistrationFormState extends State<RegistrationForm> {
// GlobalKey for accessing FormState
final _formKey = GlobalKey<FormState>();
// Form state
String _name = '';
String _email = '';
String _password = '';
bool _agree = false;
void _submit() {
// Validate all fields at once
if (_formKey.currentState!.validate()) {
// Save values from all fields
_formKey.currentState!.save();
// Process the data
_register(_name, _email, _password);
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
decoration: const InputDecoration(
labelText: 'Full Name',
prefixIcon: Icon(Icons.person),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Name cannot be empty';
}
if (value.trim().length < 3) {
return 'Name must be at least 3 characters';
}
return null; // valid
},
onSaved: (value) => _name = value!.trim(),
),
const SizedBox(height: 16),
TextFormField(
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email cannot be empty';
}
final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+');
if (!emailRegex.hasMatch(value)) {
return 'Invalid email format';
}
return null;
},
onSaved: (value) => _email = value!,
),
const SizedBox(height: 16),
TextFormField(
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password cannot be empty';
}
if (value.length < 8) {
return 'Password must be at least 8 characters';
}
if (!value.contains(RegExp(r'[A-Z]'))) {
return 'Password must contain an uppercase letter';
}
return null;
},
onSaved: (value) => _password = value!,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _submit,
child: const Text('Register'),
),
],
),
);
}
}
AutovalidateMode — When Validation Happens #
AutovalidateMode controls when TextFormField validation takes place.
// 1. disabled (default): validation ONLY when _formKey.currentState!.validate() is called
TextFormField(
autovalidateMode: AutovalidateMode.disabled,
validator: _validateEmail,
)
// 2. onUserInteraction: validation after the user starts interacting
// Error appears when the user types and leaves the field
TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: _validateEmail,
)
// 3. always: continuous validation, even before the user touches the field
// Use carefully -- can show errors too early
TextFormField(
autovalidateMode: AutovalidateMode.always,
validator: _validateEmail,
)
// Recommended pattern:
// Start with disabled, switch to onUserInteraction after the first submit
class _FormState extends State<MyForm> {
bool _submitted = false;
AutovalidateMode get _autovalidateMode => _submitted
? AutovalidateMode.onUserInteraction
: AutovalidateMode.disabled;
}
InputDecoration — Visual Customization #
InputDecoration controls all visual aspects of TextField and TextFormField:
TextFormField(
decoration: InputDecoration(
// Floating label when focused
labelText: 'Email',
labelStyle: const TextStyle(color: Colors.blue),
// Placeholder when empty
hintText: '[email protected]',
hintStyle: TextStyle(color: Colors.grey.shade400),
// Helper text below the field
helperText: 'Use an active email',
// Icons
prefixIcon: const Icon(Icons.email),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _controller.clear(),
),
// Prefix/suffix text inside the field
prefixText: '+62 ',
suffixText: '.com',
// Border styles
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.blue, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.red),
),
// Background
filled: true,
fillColor: Colors.grey.shade50,
// Content padding
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
)
Keyboard Types and TextInputAction #
// keyboardType -- which keyboard appears
TextField(keyboardType: TextInputType.text) // default: regular keyboard
TextField(keyboardType: TextInputType.emailAddress) // @ and .com
TextField(keyboardType: TextInputType.number) // numbers only
TextField(keyboardType: TextInputType.phone) // phone keypad
TextField(keyboardType: TextInputType.datetime) // date/time
TextField(keyboardType: TextInputType.url) // URL keyboard
TextField(keyboardType: TextInputType.multiline) // Enter = new line
// textInputAction -- action button in the keyboard's bottom-right corner
TextField(textInputAction: TextInputAction.next) // "Next" → move focus
TextField(textInputAction: TextInputAction.done) // "Done" → close keyboard
TextField(textInputAction: TextInputAction.search) // "Search" → magnifying glass icon
TextField(textInputAction: TextInputAction.send) // "Send"
TextField(textInputAction: TextInputAction.go) // "Go"
InputFormatters — Restrict and Format Input #
InputFormatter lets you filter or format text in real time as the user types:
import 'package:flutter/services.dart';
// Flutter built-in formatters
TextField(
inputFormatters: [
// Only allow digits
FilteringTextInputFormatter.digitsOnly,
// Only letters (no spaces, digits, symbols)
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z]')),
// Reject certain characters
FilteringTextInputFormatter.deny(RegExp(r'[<>]')),
// Length limit
LengthLimitingTextInputFormatter(10),
],
)
// Custom formatter -- phone number formatting
class PhoneInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final text = newValue.text.replaceAll(RegExp(r'[^0-9]'), '');
// Format: 0812-3456-7890
final buffer = StringBuffer();
for (int i = 0; i < text.length; i++) {
if (i == 4 || i == 8) buffer.write('-');
buffer.write(text[i]);
}
final formatted = buffer.toString();
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
}
// Using the custom formatter
TextField(
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(12),
PhoneInputFormatter(),
],
)
Other Input Widgets #
// Checkbox
CheckboxListTile(
title: const Text('I agree to the terms and conditions'),
value: _agree,
onChanged: (value) => setState(() => _agree = value!),
)
// Switch
SwitchListTile(
title: const Text('Push notifications'),
subtitle: const Text('Receive notifications from us'),
value: _notificationsEnabled,
onChanged: (value) => setState(() => _notificationsEnabled = value),
)
// Radio Buttons
Column(
children: [
RadioListTile<String>(
title: const Text('Male'),
value: 'male',
groupValue: _gender,
onChanged: (value) => setState(() => _gender = value!),
),
RadioListTile<String>(
title: const Text('Female'),
value: 'female',
groupValue: _gender,
onChanged: (value) => setState(() => _gender = value!),
),
],
)
// Slider
Slider(
value: _value,
min: 0,
max: 100,
divisions: 10,
label: '${_value.round()}',
onChanged: (v) => setState(() => _value = v),
)
// DropdownButton
DropdownButtonFormField<String>(
decoration: const InputDecoration(labelText: 'City'),
value: _selectedCity,
items: ['Jakarta', 'Bandung', 'Surabaya']
.map((city) => DropdownMenuItem(value: city, child: Text(city)))
.toList(),
onChanged: (value) => setState(() => _selectedCity = value),
validator: (value) => value == null ? 'Select a city' : null,
)
Reusable Form Patterns #
Create a custom form field widget reusable across the whole app:
class AppTextFormField extends StatelessWidget {
final String label;
final String? hint;
final IconData? prefixIcon;
final bool obscureText;
final TextInputType? keyboardType;
final TextInputAction textInputAction;
final String? Function(String?)? validator;
final void Function(String?)? onSaved;
final void Function(String)? onChanged;
final TextEditingController? controller;
final FocusNode? focusNode;
final VoidCallback? onEditingComplete;
const AppTextFormField({
super.key,
required this.label,
this.hint,
this.prefixIcon,
this.obscureText = false,
this.keyboardType,
this.textInputAction = TextInputAction.next,
this.validator,
this.onSaved,
this.onChanged,
this.controller,
this.focusNode,
this.onEditingComplete,
});
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
focusNode: focusNode,
obscureText: obscureText,
keyboardType: keyboardType,
textInputAction: textInputAction,
onEditingComplete: onEditingComplete,
validator: validator,
onSaved: onSaved,
onChanged: onChanged,
decoration: InputDecoration(
labelText: label,
hintText: hint,
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
),
);
}
}
// Usage -- clean and consistent across the whole app
AppTextFormField(
label: 'Email',
hint: '[email protected]',
prefixIcon: Icons.email,
keyboardType: TextInputType.emailAddress,
validator: Validators.email,
onSaved: (v) => _email = v!,
)
Summary #
TextFieldis a highly customizable basic input widget. Use it withTextEditingControllerfor programmatic control andFocusNodefor focus management between fields.TextFormFieldintegrates withFormandGlobalKey<FormState>for coordinated validation — ideal for forms with multiple fields.- Validators return a
Stringcontaining an error message if the input is invalid, ornullif valid.AutovalidateModecontrols when validation happens:disabled(only whenvalidate()is called),onUserInteraction(after the user interacts), oralways.InputDecorationcontrols all visual aspects — labels, hints, icons, borders, backgrounds, and error styles.InputFormatterfilters or formats input in real time. UseFilteringTextInputFormatterfor common cases and create custom ones for formats like phone numbers.- Use
textInputActionto control the keyboard action button (next,done,search) andkeyboardTypefor the appropriate keyboard type.- Create reusable custom form field widgets for visual consistency and reduced code duplication across the app.
- Always
disposeTextEditingControllerandFocusNodeindispose()to prevent memory leaks.