TextEditingController
Understand how to control and manage text input programmatically using TextEditingController.
What is it?
TextEditingController is a controller class that manages the state of a TextField or TextFormField. It provides programmatic control over the text content, selection, and composition, allowing you to read, update, and manipulate text input. It is essential for form handling, real-time validation, and controlling text fields programmatically.
Why does it exist?
TextEditingController exists to:
- Manage text field state programmatically
- Read and modify text content
- Control text selection and cursor position
- Listen to text changes
- Clear or set text values
- Handle form data and validation
- Enable real-time text processing
Basic TextEditingController
Creating and using a TextEditingController.
// Import required packages
import 'package:flutter/material.dart';
/// Basic TextEditingController example
class BasicControllerExample extends StatefulWidget {
const BasicControllerExample({super.key});
@override
State<BasicControllerExample> createState() => _BasicControllerExampleState();
}
class _BasicControllerExampleState extends State<BasicControllerExample> {
// 1. Create a TextEditingController instance
// This controller manages the text field's state
final TextEditingController _controller = TextEditingController();
@override
void initState() {
super.initState();
// 2. Add a listener to the controller
// This listener is called whenever the text changes
// It's useful for real-time validation or updating other UI elements
_controller.addListener(() {
// Access the current text using _controller.text
print('Current text: ${_controller.text}');
// You can perform any action here when text changes
// Example: update a character counter, validate input, etc.
});
}
@override
void dispose() {
// 3. Always dispose the controller when the widget is removed
// This prevents memory leaks
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('TextEditingController'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
// 4. Use the controller with a TextField
// The controller manages this TextField's state
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Enter text',
hintText: 'Type something...',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
// 5. Display the current text
// We can access the text at any time using _controller.text
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Current Text:',
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
// Access the text property to get the current value
_controller.text.isEmpty ? '(empty)' : _controller.text,
style: const TextStyle(fontSize: 16),
),
],
),
),
const SizedBox(height: 16),
// 6. Programmatic control buttons
// These demonstrate how to manipulate the controller
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Set text programmatically
ElevatedButton(
onPressed: () {
// Set the text using the text setter
// This updates the text field and triggers listeners
_controller.text = 'Hello World!';
},
child: const Text('Set Text'),
),
const SizedBox(width: 8),
// Clear the text
ElevatedButton(
onPressed: () {
// Clear the text using the clear() method
// This is equivalent to setting text to empty string
_controller.clear();
},
child: const Text('Clear'),
),
],
),
],
),
),
);
}
}
What's happening here? - TextEditingController manages text state - addListener listens for text changes - controller.text accesses current text - controller.clear() clears the text - dispose prevents memory leaks
Controller Methods and Properties
Common methods and properties of TextEditingController.
/// Demonstration of controller methods
class ControllerMethodsExample extends StatefulWidget {
const ControllerMethodsExample({super.key});
@override
State<ControllerMethodsExample> createState() => _ControllerMethodsExampleState();
}
class _ControllerMethodsExampleState extends State<ControllerMethodsExample> {
// Create the controller
final TextEditingController _controller = TextEditingController(
// Optional: Set initial text when creating the controller
// text: 'Initial text here',
);
// Track selection data
String _selectionInfo = 'No selection';
@override
void initState() {
super.initState();
// Add listener to update selection info
_controller.addListener(_updateSelectionInfo);
}
// Update selection information when text changes
void _updateSelectionInfo() {
// Get the current selection from the controller
final selection = _controller.selection;
// Check if there is a valid selection
if (selection.isValid) {
// Get the selected text using the selection range
final selectedText = _controller.text.substring(
selection.start,
selection.end,
);
setState(() {
_selectionInfo = 'Selected: "$selectedText" '
'(from ${selection.start} to ${selection.end})';
});
} else {
setState(() {
_selectionInfo = 'No selection';
});
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Controller Methods'),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// TextField with the controller
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Edit this text',
hintText: 'Type something...',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
// Display selection info
Container(
padding: const EdgeInsets.all(12),
color: Colors.blue[50],
child: Text(
_selectionInfo,
style: const TextStyle(fontSize: 14),
),
),
const SizedBox(height: 16),
// Various controller operations
Wrap(
spacing: 8,
runSpacing: 8,
children: [
// 1. Get text
_buildActionButton(
'Get Text',
() {
// Access the current text
final text = _controller.text;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Text: "$text"')),
);
},
),
// 2. Set text
_buildActionButton(
'Set Text',
() {
// Set new text
_controller.text = 'New text set at ${DateTime.now().toLocal()}';
},
),
// 3. Clear text
_buildActionButton(
'Clear',
() {
// Clear the text field
_controller.clear();
},
),
// 4. Select all text
_buildActionButton(
'Select All',
() {
// Select all text in the field
_controller.selection = TextSelection(
baseOffset: 0,
extentOffset: _controller.text.length,
);
},
),
// 5. Get selection
_buildActionButton(
'Get Selection',
() {
// Get current selection
final selection = _controller.selection;
final text = _controller.text;
// Show selection info
if (selection.isValid) {
final selected = text.substring(selection.start, selection.end);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Selected: "$selected"')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No text selected')),
);
}
},
),
// 6. Set cursor position
_buildActionButton(
'Cursor at End',
() {
// Move cursor to the end of the text
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length),
);
},
),
// 7. Replace text
_buildActionButton(
'Replace Text',
() {
// Replace a portion of text
final text = _controller.text;
if (text.isNotEmpty) {
// Replace the first word with "Hello"
final firstSpace = text.indexOf(' ');
final start = 0;
final end = firstSpace == -1 ? text.length : firstSpace;
// Create new text with replacement
final newText = 'Hello${text.substring(end)}';
_controller.text = newText;
}
},
),
],
),
],
),
),
);
}
// Helper to build action buttons
Widget _buildActionButton(String label, VoidCallback onPressed) {
return ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue[50],
foregroundColor: Colors.blue[900],
),
child: Text(label),
);
}
}
What's happening here? - controller.text: Get or set text - controller.clear(): Clear text - controller.selection: Get/set selection - controller.addListener: Listen to changes - TextSelection manages cursor and selection
Controller with TextEditingController Listeners
Using listeners to react to text changes.
/// Advanced listener usage with TextEditingController
class ListenerExample extends StatefulWidget {
const ListenerExample({super.key});
@override
State<ListenerExample> createState() => _ListenerExampleState();
}
class _ListenerExampleState extends State<ListenerExample> {
// Create the controller
final TextEditingController _controller = TextEditingController();
// State variables for tracking changes
int _characterCount = 0;
bool _isValid = false;
String _lastChange = 'None';
@override
void initState() {
super.initState();
// Add a listener to the controller
// This listener is called every time the text changes
_controller.addListener(() {
// Update state based on text changes
setState(() {
// Get the current text
final text = _controller.text;
// Update character count
_characterCount = text.length;
// Validate the text (example: at least 3 characters)
_isValid = text.length >= 3;
// Track last change time
_lastChange = DateTime.now().toLocal().toString();
});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Controller Listeners'),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Text field with real-time feedback
TextField(
controller: _controller,
decoration: InputDecoration(
labelText: 'Type something...',
hintText: 'Minimum 3 characters',
border: const OutlineInputBorder(),
// Show character counter
helperText: '$_characterCount characters',
// Show validation state with colors
helperStyle: TextStyle(
color: _isValid ? Colors.green : Colors.grey,
),
// Show error if invalid
errorText: _characterCount > 0 && !_isValid
? 'Minimum 3 characters required'
: null,
),
),
const SizedBox(height: 16),
// Real-time feedback
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _isValid ? Colors.green[50] : Colors.grey[50],
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _isValid ? Colors.green : Colors.grey,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Status indicator
Icon(
_isValid ? Icons.check_circle : Icons.info,
color: _isValid ? Colors.green : Colors.grey,
),
const SizedBox(width: 8),
Text(
_isValid ? 'Valid Input' : 'Invalid Input',
style: TextStyle(
fontWeight: FontWeight.bold,
color: _isValid ? Colors.green : Colors.grey,
),
),
const Spacer(),
Text(
'$_characterCount chars',
style: const TextStyle(fontSize: 12),
),
],
),
const SizedBox(height: 8),
Text(
'Text: "${_controller.text.isEmpty ? 'empty' : _controller.text}"',
style: const TextStyle(fontSize: 14),
),
Text(
'Last changed: $_lastChange',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),
const SizedBox(height: 16),
// Control buttons
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Add text
ElevatedButton.icon(
onPressed: () {
// Append text to the current text
final current = _controller.text;
_controller.text = current + ' appended';
},
icon: const Icon(Icons.add),
label: const Text('Append'),
),
const SizedBox(width: 8),
// Clear text
ElevatedButton.icon(
onPressed: _controller.clear,
icon: const Icon(Icons.clear),
label: const Text('Clear'),
),
],
),
],
),
),
);
}
}
What's happening here? - addListener triggers on every change - Real-time validation and feedback - Character counting - Status indicators
Controller with Focus Management
Managing focus with TextEditingController.
/// Focus management with TextEditingController
class FocusControllerExample extends StatefulWidget {
const FocusControllerExample({super.key});
@override
State<FocusControllerExample> createState() => _FocusControllerExampleState();
}
class _FocusControllerExampleState extends State<FocusControllerExample> {
// Controllers for each field
final TextEditingController _nameController = TextEditingController();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
// Focus nodes for each field
// FocusNode manages focus state for a widget
final FocusNode _nameFocus = FocusNode();
final FocusNode _emailFocus = FocusNode();
final FocusNode _passwordFocus = FocusNode();
@override
void initState() {
super.initState();
// Add listeners to handle focus events
// This is useful for focusing the next field when done
_nameFocus.addListener(() {
// When name field loses focus, move to email field
if (!_nameFocus.hasFocus) {
// Request focus on the email field
// This moves the cursor to the email field
_emailFocus.requestFocus();
}
});
}
@override
void dispose() {
// Dispose all controllers and focus nodes
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_nameFocus.dispose();
_emailFocus.dispose();
_passwordFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Focus Management'),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// 1. Name field with focus management
TextField(
controller: _nameController,
focusNode: _nameFocus,
decoration: const InputDecoration(
labelText: 'Name',
hintText: 'Enter your name',
border: OutlineInputBorder(),
),
// Called when user presses "Next" on keyboard
// This moves focus to the email field
onEditingComplete: () {
// Request focus on email field
_emailFocus.requestFocus();
},
),
const SizedBox(height: 16),
// 2. Email field
TextField(
controller: _emailController,
focusNode: _emailFocus,
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'Enter your email',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
// Move to password field when next is pressed
onEditingComplete: () {
_passwordFocus.requestFocus();
},
),
const SizedBox(height: 16),
// 3. Password field
TextField(
controller: _passwordController,
focusNode: _passwordFocus,
decoration: const InputDecoration(
labelText: 'Password',
hintText: 'Enter your password',
border: OutlineInputBorder(),
),
obscureText: true,
// Callback when user submits (presses done)
// This typically performs the form submission
onEditingComplete: () {
// Submit the form
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Form submitted!'),
backgroundColor: Colors.green,
),
);
// Dismiss keyboard
_passwordFocus.unfocus();
},
),
const SizedBox(height: 24),
// Focus control buttons
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Focus name field
ElevatedButton(
onPressed: () {
// Request focus on name field
_nameFocus.requestFocus();
},
child: const Text('Focus Name'),
),
const SizedBox(width: 8),
// Focus email field
ElevatedButton(
onPressed: () {
_emailFocus.requestFocus();
},
child: const Text('Focus Email'),
),
const SizedBox(width: 8),
// Unfocus all (dismiss keyboard)
ElevatedButton(
onPressed: () {
// Unfocus all fields (hides keyboard)
FocusScope.of(context).unfocus();
},
child: const Text('Dismiss Keyboard'),
),
],
),
],
),
),
);
}
}
What's happening here? - FocusNode manages field focus - requestFocus() focuses a field - unfocus() dismisses keyboard - onEditingComplete handles submit
Real-World Examples
Common patterns with TextEditingController.
/// 1. Real-time search with controller
class RealTimeSearchExample extends StatefulWidget {
const RealTimeSearchExample({super.key});
@override
State<RealTimeSearchExample> createState() => _RealTimeSearchExampleState();
}
class _RealTimeSearchExampleState extends State<RealTimeSearchExample> {
// Controller for search field
final TextEditingController _searchController = TextEditingController();
// Timer for debouncing
Timer? _debounceTimer;
// List to store search results
List<String> _results = [];
// Sample data
final List<String> _allItems = [
'Apple', 'Banana', 'Orange', 'Grape', 'Watermelon',
'Strawberry', 'Pineapple', 'Mango', 'Peach', 'Pear',
'Cherry', 'Lemon', 'Lime', 'Berry', 'Melon',
];
@override
void initState() {
super.initState();
// Add listener for real-time search
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_debounceTimer?.cancel();
_searchController.dispose();
super.dispose();
}
// Called when search text changes
void _onSearchChanged() {
// Cancel any pending debounce
_debounceTimer?.cancel();
// Debounce search to avoid heavy operations
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
final query = _searchController.text;
setState(() {
if (query.isEmpty) {
_results = [];
} else {
// Filter items based on query
_results = _allItems
.where((item) => item
.toLowerCase()
.contains(query.toLowerCase()))
.toList();
}
});
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Real-Time Search'),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Search field with clear button
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search fruits...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
// Clear the search field
_searchController.clear();
},
)
: null,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
filled: true,
fillColor: Colors.grey[50],
),
),
const SizedBox(height: 16),
// Show results
Expanded(
child: _results.isEmpty
? Center(
child: Text(
_searchController.text.isEmpty
? 'Start typing to search'
: 'No results found',
style: TextStyle(
color: Colors.grey[600],
fontSize: 16,
),
),
)
: ListView.builder(
itemCount: _results.length,
itemBuilder: (context, index) {
return ListTile(
leading: const Icon(Icons.search),
title: Text(_results[index]),
// Highlight matching text
subtitle: Text(
'Contains: ${_searchController.text}',
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
);
},
),
),
],
),
),
);
}
}
/// 2. Auto-resizing text field
class AutoResizeTextExample extends StatefulWidget {
const AutoResizeTextExample({super.key});
@override
State<AutoResizeTextExample> createState() => _AutoResizeTextExampleState();
}
class _AutoResizeTextExampleState extends State<AutoResizeTextExample> {
final TextEditingController _controller = TextEditingController();
final int _maxLines = 10;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Auto-Resize TextField'),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Auto-resizing text field
TextField(
controller: _controller,
maxLines: _maxLines,
minLines: 1,
decoration: const InputDecoration(
labelText: 'Write something...',
hintText: 'This field grows as you type',
border: OutlineInputBorder(),
helperText: 'Press Enter to add new lines',
),
// Called when user presses Enter
onSubmitted: (value) {
// This doesn't close the field; it just adds a new line
},
),
const SizedBox(height: 16),
// Character and line count
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Statistics',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 4),
Text('Characters: ${_controller.text.length}'),
Text('Words: ${_controller.text.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).length}'),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const Text(
'Lines',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 4),
Text('${_controller.text.split('\n').length}'),
Text('Max: $_maxLines'),
],
),
],
),
),
],
),
),
);
}
}
What's happening here? - Real-time search with debouncing - Auto-resizing text field - Character and word counting - Clear button in search field
Best Practices
Always Dispose Controllers
// Good - Dispose in dispose()
class MyWidget extends StatefulWidget {
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
final TextEditingController _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
Use Listeners for Real-Time Updates
// Good - Listen for changes
@override
void initState() {
super.initState();
_controller.addListener(() {
// React to changes
});
}
Clear Controllers When Needed
// Good - Clear text
_controller.clear();
// Or
_controller.text = '';
Common Mistakes
Not Disposing Controller
Wrong:
class MyWidget extends StatefulWidget {
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
final TextEditingController _controller = TextEditingController();
// No dispose - memory leak
}
Correct:
class _MyWidgetState extends State<MyWidget> {
final TextEditingController _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
Not Using TextEditingController for Custom Text
Wrong:
// Setting initial value without controller
TextField(
initialValue: 'Initial text', // Works but less control
)
Correct:
// Using controller for full control
final _controller = TextEditingController(text: 'Initial text');
TextField(controller: _controller)
Summary
TextEditingController provides programmatic control over TextField and TextFormField. Use it to read, write, and manage text content, selection, and focus. Always dispose controllers to prevent memory leaks. Use listeners for real-time updates and validation.
Next Steps
Did You Know?
- TextEditingController must be disposed
- controller.text gets/sets text
- controller.clear() clears text
- controller.selection manages cursor
- addListener reacts to changes
- FocusNode manages keyboard focus
- Controllers can have initial text
- TextEditingController is not thread-safe