Input Widgets: TextField & Checkbox
The TextField Widget
TextField is Flutter’s primary widget for text input. It allows users to type text, numbers, passwords, and other data. TextField is highly customizable through its InputDecoration property, which controls the visual appearance of the input field including labels, hints, icons, borders, and error states.
Basic TextField
// Simple text field
const TextField(
decoration: InputDecoration(
labelText: 'Username',
hintText: 'Enter your username',
),
)
// Text field with border
const TextField(
decoration: InputDecoration(
labelText: 'Email',
hintText: 'you@example.com',
border: OutlineInputBorder(),
),
)
TextEditingController
To read, set, or listen to changes in a TextField’s value, you use a TextEditingController. This is one of the most important concepts when working with text input in Flutter.
Using TextEditingController
class MyForm extends StatefulWidget {
const MyForm({super.key});
@override
State<MyForm> createState() => _MyFormState();
}
class _MyFormState extends State<MyForm> {
// Create the controller
final _nameController = TextEditingController();
@override
void dispose() {
// Always dispose controllers to prevent memory leaks
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Full Name',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// Read the current value
final name = _nameController.text;
debugPrint('Name: \$name');
// Set a value programmatically
// _nameController.text = 'New Value';
// Clear the field
// _nameController.clear();
},
child: const Text('Submit'),
),
],
);
}
}
TextEditingController in the dispose() method of your State class. Failing to do so causes memory leaks, as the controller continues to listen for changes even after the widget is removed from the tree.InputDecoration Deep Dive
InputDecoration provides extensive customization for the TextField’s appearance:
InputDecoration Properties
TextField(
decoration: InputDecoration(
// Labels and hints
labelText: 'Email Address',
hintText: 'you@example.com',
helperText: 'We will never share your email',
// Prefix and suffix
prefixIcon: const Icon(Icons.email),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
// Clear field
},
),
// Prefix/suffix text (inline)
// prefixText: '\$',
// suffixText: 'USD',
// Border styles
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: Colors.blue,
width: 2,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.grey[300]!,
),
),
// Fill
filled: true,
fillColor: Colors.grey[50],
// Content padding
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
)
Error State
Display validation errors using errorText:
TextField with Error Handling
class EmailField extends StatefulWidget {
const EmailField({super.key});
@override
State<EmailField> createState() => _EmailFieldState();
}
class _EmailFieldState extends State<EmailField> {
final _controller = TextEditingController();
String? _errorText;
void _validate() {
setState(() {
final text = _controller.text;
if (text.isEmpty) {
_errorText = 'Email is required';
} else if (!text.contains('@')) {
_errorText = 'Please enter a valid email';
} else {
_errorText = null;
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
decoration: InputDecoration(
labelText: 'Email',
prefixIcon: const Icon(Icons.email),
border: const OutlineInputBorder(),
errorText: _errorText,
errorBorder: const OutlineInputBorder(
borderSide: BorderSide(color: Colors.red),
),
),
onChanged: (value) => _validate(),
keyboardType: TextInputType.emailAddress,
);
}
}
TextField Callbacks
TextField provides several callback options for responding to user input:
TextField Callbacks
TextField(
// Called every time the text changes
onChanged: (String value) {
debugPrint('Current value: \$value');
},
// Called when the user presses the action button (Enter/Done)
onSubmitted: (String value) {
debugPrint('Submitted: \$value');
},
// Called when the field gains/loses focus
onTap: () {
debugPrint('Field tapped');
},
// Called when editing is complete (field loses focus)
onEditingComplete: () {
debugPrint('Editing complete');
},
decoration: const InputDecoration(
labelText: 'Search',
border: OutlineInputBorder(),
),
// Keyboard action button type
textInputAction: TextInputAction.search,
)
Password Field with Visibility Toggle
The obscureText property hides the input characters, commonly used for password fields:
Password Field
class PasswordField extends StatefulWidget {
const PasswordField({super.key});
@override
State<PasswordField> createState() => _PasswordFieldState();
}
class _PasswordFieldState extends State<PasswordField> {
final _controller = TextEditingController();
bool _obscure = true;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
obscureText: _obscure,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscure ? Icons.visibility_off : Icons.visibility,
),
onPressed: () {
setState(() {
_obscure = !_obscure;
});
},
),
border: const OutlineInputBorder(),
),
);
}
}
TextField Keyboard Types
Set the keyboardType to show the appropriate keyboard layout:
Keyboard Types
// Email keyboard (with @ and .com)
TextField(
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(labelText: 'Email'),
)
// Number keyboard
TextField(
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Age'),
)
// Phone keyboard
TextField(
keyboardType: TextInputType.phone,
decoration: const InputDecoration(labelText: 'Phone'),
)
// Multiline text
TextField(
keyboardType: TextInputType.multiline,
maxLines: 4,
decoration: const InputDecoration(
labelText: 'Bio',
alignLabelWithHint: true,
border: OutlineInputBorder(),
),
)
// URL keyboard
TextField(
keyboardType: TextInputType.url,
decoration: const InputDecoration(labelText: 'Website'),
)
The Checkbox Widget
The Checkbox widget is a Material Design checkbox that can be either checked or unchecked. It requires a value and an onChanged callback.
Basic Checkbox
class CheckboxDemo extends StatefulWidget {
const CheckboxDemo({super.key});
@override
State<CheckboxDemo> createState() => _CheckboxDemoState();
}
class _CheckboxDemoState extends State<CheckboxDemo> {
bool _isChecked = false;
@override
Widget build(BuildContext context) {
return Row(
children: [
Checkbox(
value: _isChecked,
onChanged: (bool? value) {
setState(() {
_isChecked = value ?? false;
});
},
),
const Text('I agree to the terms and conditions'),
],
);
}
}
Key Checkbox properties:
value-- Whether the checkbox is checked (true), unchecked (false), or in a tristate (null).onChanged-- Callback when the checkbox is tapped. Set to null to disable the checkbox.activeColor-- Color of the checkbox when checked.checkColor-- Color of the check icon inside the checkbox.tristate-- If true, the checkbox can also have a null (indeterminate) state.shape-- The shape of the checkbox (e.g., rounded).
CheckboxListTile
CheckboxListTile combines a Checkbox with a ListTile for a cleaner, more tappable layout:
CheckboxListTile Examples
class TaskList extends StatefulWidget {
const TaskList({super.key});
@override
State<TaskList> createState() => _TaskListState();
}
class _TaskListState extends State<TaskList> {
final Map<String, bool> _tasks = {
'Buy groceries': false,
'Clean the house': true,
'Walk the dog': false,
'Read a book': true,
};
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: _tasks.entries.map((entry) {
return CheckboxListTile(
title: Text(
entry.key,
style: TextStyle(
decoration: entry.value
? TextDecoration.lineThrough
: null,
color: entry.value ? Colors.grey : null,
),
),
value: entry.value,
onChanged: (bool? value) {
setState(() {
_tasks[entry.key] = value ?? false;
});
},
controlAffinity:
ListTileControlAffinity.leading,
activeColor: Colors.green,
);
}).toList(),
),
);
}
}
controlAffinity determines where the checkbox appears: ListTileControlAffinity.leading places it at the start (common for task lists), while ListTileControlAffinity.trailing places it at the end (common for settings). The default is platform which varies by OS.Tristate Checkbox
A tristate checkbox has three states: checked (true), unchecked (false), and indeterminate (null). This is useful for "select all" scenarios:
Tristate Checkbox Example
class SelectAllDemo extends StatefulWidget {
const SelectAllDemo({super.key});
@override
State<SelectAllDemo> createState() => _SelectAllDemoState();
}
class _SelectAllDemoState extends State<SelectAllDemo> {
final List<bool> _items = [false, true, false];
bool? get _selectAll {
if (_items.every((item) => item)) return true;
if (_items.every((item) => !item)) return false;
return null; // indeterminate
}
@override
Widget build(BuildContext context) {
return Column(
children: [
CheckboxListTile(
title: const Text(
'Select All',
style: TextStyle(fontWeight: FontWeight.bold),
),
tristate: true,
value: _selectAll,
onChanged: (bool? value) {
setState(() {
final newValue = value ?? false;
for (int i = 0; i < _items.length; i++) {
_items[i] = newValue;
}
});
},
),
const Divider(),
...List.generate(_items.length, (index) {
return CheckboxListTile(
title: Text('Item \${index + 1}'),
value: _items[index],
onChanged: (bool? value) {
setState(() {
_items[index] = value ?? false;
});
},
controlAffinity: ListTileControlAffinity.leading,
);
}),
],
);
}
}
Practical Example: Login Form
Complete Login Form
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
bool _rememberMe = false;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Welcome Back',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText: 'Email',
prefixIcon: const Icon(Icons.email_outlined),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 8),
CheckboxListTile(
title: const Text('Remember me'),
value: _rememberMe,
onChanged: (value) {
setState(() {
_rememberMe = value ?? false;
});
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
debugPrint('Email: \${_emailController.text}');
debugPrint('Remember: \$_rememberMe');
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text('Log In', style: TextStyle(fontSize: 16)),
),
],
),
),
),
);
}
}
Practical Example: Todo Checklist
Todo Checklist with TextField
class TodoChecklist extends StatefulWidget {
const TodoChecklist({super.key});
@override
State<TodoChecklist> createState() => _TodoChecklistState();
}
class _TodoChecklistState extends State<TodoChecklist> {
final _controller = TextEditingController();
final List<Map<String, dynamic>> _todos = [];
void _addTodo() {
if (_controller.text.isNotEmpty) {
setState(() {
_todos.add({
'title': _controller.text,
'done': false,
});
_controller.clear();
});
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Todo List')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _controller,
onSubmitted: (_) => _addTodo(),
decoration: InputDecoration(
hintText: 'Add a new task...',
suffixIcon: IconButton(
icon: const Icon(Icons.add_circle),
onPressed: _addTodo,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
Expanded(
child: ListView.builder(
itemCount: _todos.length,
itemBuilder: (context, index) {
final todo = _todos[index];
return CheckboxListTile(
title: Text(
todo['title'] as String,
style: TextStyle(
decoration: (todo['done'] as bool)
? TextDecoration.lineThrough
: null,
),
),
value: todo['done'] as bool,
onChanged: (value) {
setState(() {
_todos[index]['done'] = value;
});
},
controlAffinity:
ListTileControlAffinity.leading,
secondary: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () {
setState(() {
_todos.removeAt(index);
});
},
),
);
},
),
),
],
),
);
}
}
Practice Exercise
Build a registration form screen with the following: (1) Four TextFields: full name, email, phone number, and password -- each with appropriate keyboardType, InputDecoration with prefix icons, and validation error text. (2) A password visibility toggle using obscureText and a suffix icon. (3) A CheckboxListTile for "I agree to the terms". (4) A submit button that is disabled when the checkbox is unchecked. (5) Display validation errors using errorText when the user submits with empty fields. Challenge: Add a password strength indicator that updates in real-time as the user types, using onChanged.