Collect user input with TextField, TextFormField, Form validation, and specialized input widgets.
TextField is the basic text input widget. Use decoration to add labels, hints, borders, icons, and error messages.
TextField(
// Controller to read and control the value
controller: _nameController,
// Keyboard type
keyboardType: TextInputType.emailAddress,
// Options: text | number | phone | emailAddress | url | multiline | datetime
// Action button on keyboard
textInputAction: TextInputAction.next, // shows "next" key
// Options: done | next | go | search | send
// Decoration (visual styling)
decoration: const InputDecoration(
labelText: 'Email Address',
hintText: 'Enter your email',
helperText: 'We will never share your email',
prefixIcon: Icon(Icons.email),
suffixIcon: Icon(Icons.clear),
border: OutlineInputBorder(),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.blue, width: 2),
),
filled: true,
fillColor: Colors.grey,
),
// Callbacks
onChanged: (value) => print('Typing: $value'),
onSubmitted: (value) => print('Submitted: $value'),
onTap: () => print('Field tapped'),
// Input restrictions
maxLength: 100, // shows character counter
maxLines: 1, // 1 = single line, null = unlimited
minLines: 3, // multiline minimum height
obscureText: true, // password field (hides characters)
readOnly: true, // can focus but not edit
enabled: false, // completely disabled (greyed out)
// Auto-correct and capitalization
autocorrect: false,
enableSuggestions: false,
textCapitalization: TextCapitalization.sentences,
)
A TextEditingController lets you read the current text, set text programmatically, clear the field, and listen to changes.
class MyFormState extends State<MyForm> {
// Create controllers — one per TextField
final TextEditingController _nameController = TextEditingController();
final TextEditingController _emailController = TextEditingController();
@override
void initState() {
super.initState();
// Pre-fill a field
_nameController.text = 'Alice';
// Listen to changes without onChanged callback
_nameController.addListener(() {
print('Name changed: ${_nameController.text}');
});
}
@override
void dispose() {
// ALWAYS dispose controllers to prevent memory leaks
_nameController.dispose();
_emailController.dispose();
super.dispose();
}
void _submit() {
final name = _nameController.text.trim();
final email = _emailController.text.trim();
print('Name: $name, Email: $email');
}
void _clearAll() {
_nameController.clear();
_emailController.clear();
}
void _setName(String value) {
_nameController.text = value;
// Move cursor to end after setting text
_nameController.selection = TextSelection.fromPosition(
TextPosition(offset: _nameController.text.length),
);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(controller: _nameController, decoration: const InputDecoration(labelText: 'Name')),
TextField(controller: _emailController, decoration: const InputDecoration(labelText: 'Email')),
ElevatedButton(onPressed: _submit, child: const Text('Submit')),
TextButton(onPressed: _clearAll, child: const Text('Clear')),
],
);
}
}
dispose(). Forgetting to dispose causes memory leaks because the controller keeps listening after the widget is removed.Form is a container that groups TextFormField widgets. It uses a GlobalKey<FormState> to validate and save all fields at once.
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
// The key gives us programmatic access to the Form's state
final _formKey = GlobalKey<FormState>();
// Store values from form fields
String _email = '';
String _password = '';
void _submit() {
// Triggers all validators — returns true if all pass
if (_formKey.currentState!.validate()) {
// Save all TextFormField values into their onSaved callbacks
_formKey.currentState!.save();
print('Login: $_email / $_password');
}
}
void _reset() {
_formKey.currentState!.reset(); // clears all fields and errors
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(/* see next section */),
TextFormField(/* see next section */),
ElevatedButton(onPressed: _submit, child: const Text('Login')),
TextButton(onPressed: _reset, child: const Text('Reset')),
],
),
);
}
}
TextFormField is like TextField but designed to work inside a Form. It adds validator and onSaved callbacks.
// Email field
TextFormField(
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
// Validator: return an error string or null if valid
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Email is required';
}
final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+$');
if (!emailRegex.hasMatch(value.trim())) {
return 'Enter a valid email address';
}
return null; // null means valid
},
// Called when formKey.currentState!.save() is invoked
onSaved: (value) => _email = value!.trim(),
)
// Password field
TextFormField(
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
onPressed: () => setState(() => _obscure = !_obscure),
),
),
obscureText: _obscure,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
validator: (value) {
if (value == null || value.isEmpty) return 'Password is required';
if (value.length < 8) return 'Password must be at least 8 characters';
return null;
},
onSaved: (value) => _password = value!,
)
// STEP 1: Create the key
final _formKey = GlobalKey<FormState>();
// STEP 2: Wrap fields in Form with the key
Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
// AutovalidateMode options:
// disabled — only validate on manual call (default)
// onUserInteraction — validate as user types after first interaction
// always — validate constantly
child: Column(children: [ /* TextFormField widgets */ ]),
)
// STEP 3: Validate on submit
void _onSubmit() {
// Runs all validators — returns true if all return null
final isValid = _formKey.currentState!.validate();
if (isValid) {
_formKey.currentState!.save(); // calls all onSaved callbacks
// proceed with the data
_doLogin(_email, _password);
}
}
// Common validator patterns:
String? requiredValidator(String? value) {
if (value == null || value.trim().isEmpty) return 'This field is required';
return null;
}
String? emailValidator(String? value) {
if (value == null || value.isEmpty) return 'Required';
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(value)) return 'Invalid email';
return null;
}
String? minLengthValidator(int min) => (String? value) {
if (value == null || value.length < min) return 'Min $min characters';
return null;
};
// ---- Checkbox ----
bool _isChecked = false;
Checkbox(
value: _isChecked,
onChanged: (val) => setState(() => _isChecked = val!),
activeColor: Colors.blue,
)
// CheckboxListTile — checkbox with a label (common pattern)
CheckboxListTile(
title: const Text('Remember me'),
subtitle: const Text('Stay logged in on this device'),
value: _rememberMe,
onChanged: (val) => setState(() => _rememberMe = val!),
secondary: const Icon(Icons.lock),
)
// ---- Switch ----
bool _isDarkMode = false;
Switch(
value: _isDarkMode,
onChanged: (val) => setState(() => _isDarkMode = val),
activeColor: Colors.blue,
)
// SwitchListTile
SwitchListTile(
title: const Text('Dark Mode'),
value: _isDarkMode,
onChanged: (val) => setState(() => _isDarkMode = val),
)
// ---- Radio ----
String _gender = 'male';
Column(
children: [
RadioListTile<String>(
title: const Text('Male'),
value: 'male',
groupValue: _gender,
onChanged: (val) => setState(() => _gender = val!),
),
RadioListTile<String>(
title: const Text('Female'),
value: 'female',
groupValue: _gender,
onChanged: (val) => setState(() => _gender = val!),
),
],
)
// ---- Slider ----
double _volume = 0.5;
Slider(
value: _volume,
min: 0.0,
max: 1.0,
divisions: 10, // snaps to 10 equal steps
label: '${(_volume * 100).round()}%',
onChanged: (val) => setState(() => _volume = val),
)
// RangeSlider — pick a range
RangeValues _priceRange = const RangeValues(20, 80);
RangeSlider(
values: _priceRange,
min: 0,
max: 200,
divisions: 20,
labels: RangeLabels('\$${_priceRange.start.round()}', '\$${_priceRange.end.round()}'),
onChanged: (range) => setState(() => _priceRange = range),
)
// ---- DropdownButton (standalone) ----
String? _selectedCountry = 'Myanmar';
DropdownButton<String>(
value: _selectedCountry,
hint: const Text('Select country'),
isExpanded: true, // fills available width
underline: const SizedBox(), // remove default underline
items: ['Myanmar', 'Thailand', 'Singapore', 'Malaysia']
.map((country) => DropdownMenuItem(
value: country,
child: Text(country),
))
.toList(),
onChanged: (value) => setState(() => _selectedCountry = value),
)
// ---- DropdownButtonFormField (inside a Form, with validation) ----
DropdownButtonFormField<String>(
value: _selectedRole,
decoration: const InputDecoration(
labelText: 'Role',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
items: ['Admin', 'Editor', 'Viewer']
.map((role) => DropdownMenuItem(value: role, child: Text(role)))
.toList(),
validator: (value) => value == null ? 'Please select a role' : null,
onChanged: (value) => setState(() => _selectedRole = value),
onSaved: (value) => _role = value!,
)
DateTime? _selectedDate;
TimeOfDay? _selectedTime;
// ---- Date Picker ----
Future<void> _pickDate() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
helpText: 'Select a date',
cancelText: 'Cancel',
confirmText: 'Pick',
builder: (context, child) {
// Custom theme for the picker dialog
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.dark(primary: Colors.blue),
),
child: child!,
);
},
);
if (picked != null) {
setState(() => _selectedDate = picked);
print('Date: ${picked.toIso8601String()}');
}
}
// ---- Time Picker ----
Future<void> _pickTime() async {
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
initialEntryMode: TimePickerEntryMode.dial,
);
if (picked != null) {
setState(() => _selectedTime = picked);
print('Time: ${picked.format(context)}');
}
}
// Display widgets
ElevatedButton.icon(
onPressed: _pickDate,
icon: const Icon(Icons.calendar_today),
label: Text(
_selectedDate == null
? 'Pick Date'
: '${_selectedDate!.day}/${_selectedDate!.month}/${_selectedDate!.year}',
),
)
ElevatedButton.icon(
onPressed: _pickTime,
icon: const Icon(Icons.access_time),
label: Text(
_selectedTime == null ? 'Pick Time' : _selectedTime!.format(context),
),
)
class LoginForm extends StatefulWidget {
const LoginForm({super.key});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final _formKey = GlobalKey<FormState>();
final _emailCtrl = TextEditingController();
final _passCtrl = TextEditingController();
bool _obscurePass = true;
bool _rememberMe = false;
bool _isLoading = false;
@override
void dispose() {
_emailCtrl.dispose();
_passCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
await Future.delayed(const Duration(seconds: 2)); // simulate API
setState(() => _isLoading = false);
if (mounted) Navigator.pushReplacementNamed(context, '/home');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Welcome Back', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
const SizedBox(height: 32),
TextFormField(
controller: _emailCtrl,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
validator: (v) {
if (v == null || v.isEmpty) return 'Email required';
if (!v.contains('@')) return 'Invalid email';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passCtrl,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_obscurePass ? Icons.visibility : Icons.visibility_off),
onPressed: () => setState(() => _obscurePass = !_obscurePass),
),
),
obscureText: _obscurePass,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
validator: (v) {
if (v == null || v.isEmpty) return 'Password required';
if (v.length < 6) return 'Min 6 characters';
return null;
},
),
const SizedBox(height: 8),
CheckboxListTile(
title: const Text('Remember me'),
value: _rememberMe,
onChanged: (v) => setState(() => _rememberMe = v!),
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _isLoading ? null : _submit,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 14)),
child: _isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Login', style: TextStyle(fontSize: 16)),
),
const SizedBox(height: 12),
TextButton(onPressed: () {}, child: const Text('Forgot Password?')),
],
),
),
),
);
}
}