What “user input” means in Flutter
User input is any interaction where the user changes app state: tapping a button, typing text, selecting a field, or performing a gesture like a long-press. In Flutter, you typically handle input through:
- Buttons (e.g.,
ElevatedButton,TextButton) for clear actions. - Tap/gesture surfaces (e.g.,
InkWell,GestureDetector) for custom interactive areas. - Text input (e.g.,
TextField,TextFormField) for typed data. - Focus management (e.g.,
FocusNode,FocusScope) to control keyboard behavior and field navigation. - Validation with
Form+TextFormFieldto ensure input is correct before submitting.
In this chapter, you’ll build a simple “Create Profile” screen that validates fields, manages focus, and provides feedback with a SnackBar and a dialog.
Build a simple input screen: Create Profile
Step 1: Create a stateful screen with controllers and focus nodes
For text inputs, you’ll commonly use TextEditingController to read/write the field value and FocusNode to move focus between fields and control the keyboard.
Rule of thumb: if you create a controller or focus node in your widget, you must dispose it in dispose().
import 'package:flutter/material.dart';
class CreateProfilePage extends StatefulWidget {
const CreateProfilePage({super.key});
@override
State<CreateProfilePage> createState() => _CreateProfilePageState();
}
class _CreateProfilePageState extends State<CreateProfilePage> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _bioController = TextEditingController();
final _nameFocus = FocusNode();
final _emailFocus = FocusNode();
final _bioFocus = FocusNode();
bool _submitting = false;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_bioController.dispose();
_nameFocus.dispose();
_emailFocus.dispose();
_bioFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Create Profile')),
body: const SizedBox(),
);
}
}Step 2: Add a Form with TextFormField validation
Form groups multiple fields and allows you to validate them together. Each TextFormField can define a validator that returns:
- Listen to the audio with the screen off.
- Earn a certificate upon completion.
- Over 5000 courses for you to explore!
Download the app
nullwhen valid- a string error message when invalid
Also note these useful properties:
textInputAction: controls the keyboard action button (Next/Done).onFieldSubmitted: move focus to the next field or submit.autovalidateMode: optionally validate as the user types/changes focus.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Create Profile')),
body: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => FocusScope.of(context).unfocus(),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _nameController,
focusNode: _nameFocus,
decoration: const InputDecoration(
labelText: 'Name',
hintText: 'e.g., Alex Johnson',
),
textInputAction: TextInputAction.next,
validator: (value) {
final text = value?.trim() ?? '';
if (text.isEmpty) return 'Name is required';
if (text.length < 2) return 'Name is too short';
return null;
},
onFieldSubmitted: (_) {
FocusScope.of(context).requestFocus(_emailFocus);
},
),
const SizedBox(height: 12),
TextFormField(
controller: _emailController,
focusNode: _emailFocus,
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'e.g., alex@example.com',
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
validator: (value) {
final text = value?.trim() ?? '';
if (text.isEmpty) return 'Email is required';
final emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
if (!emailRegex.hasMatch(text)) return 'Enter a valid email';
return null;
},
onFieldSubmitted: (_) {
FocusScope.of(context).requestFocus(_bioFocus);
},
),
const SizedBox(height: 12),
TextFormField(
controller: _bioController,
focusNode: _bioFocus,
decoration: const InputDecoration(
labelText: 'Bio',
hintText: 'A short description (optional)',
),
maxLines: 3,
textInputAction: TextInputAction.done,
validator: (value) {
final text = value?.trim() ?? '';
if (text.length > 120) return 'Bio must be 120 characters or less';
return null;
},
onFieldSubmitted: (_) => _submit(),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save Profile'),
),
],
),
),
),
),
),
);
}Managing focus and keyboard behavior
Common focus patterns
- Tap outside to dismiss keyboard: wrap the screen in a
GestureDetectorand callFocusScope.of(context).unfocus(). - Next/Done navigation: use
textInputActionandonFieldSubmittedto move focus. - Programmatic focus: call
FocusScope.of(context).requestFocus(focusNode).
These patterns make forms feel “native” and reduce friction when users fill multiple fields.
Submitting the form and giving user feedback
Step 3: Implement submit logic with validation
When the user taps “Save Profile”, you typically:
- Dismiss the keyboard
- Validate the form
- Read controller values
- Show feedback (SnackBar/dialog)
Future<void> _submit() async {
FocusScope.of(context).unfocus();
final isValid = _formKey.currentState?.validate() ?? false;
if (!isValid) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please fix the errors in the form')),
);
return;
}
setState(() => _submitting = true);
try {
final name = _nameController.text.trim();
final email = _emailController.text.trim();
final bio = _bioController.text.trim();
await Future.delayed(const Duration(milliseconds: 600));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Saved profile for $name')),
);
await showDialog<void>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Profile Created'),
content: Text('Name: $name\nEmail: $email\nBio: ${bio.isEmpty ? "(none)" : bio}'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
);
},
);
} finally {
if (mounted) {
setState(() => _submitting = false);
}
}
}SnackBar vs dialog: when to use which
- SnackBar: lightweight, temporary feedback (saved, error, undo). Best for non-blocking messages.
- Dialog: requires acknowledgment or shows important details. Use sparingly because it interrupts flow.
Buttons and tap surfaces: InkWell vs GestureDetector
Use buttons for primary actions
For actions like “Save”, prefer a button widget. Buttons provide accessibility defaults (tap target, semantics) and consistent styling.
ElevatedButton(
onPressed: _submitting ? null : _submit,
child: const Text('Save Profile'),
)Use InkWell for Material ripple feedback
InkWell is ideal when you want a custom clickable area that still shows a ripple effect (Material design). It must have a Material ancestor to paint the ink splash correctly.
Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tip: Keep your bio short and clear.')),
);
},
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: const [
Icon(Icons.info_outline),
SizedBox(width: 8),
Expanded(child: Text('Tap for a profile tip')),
],
),
),
),
)Use GestureDetector for raw gestures
GestureDetector is more general and supports many gestures (tap, double tap, long press, pan). It does not provide a ripple by default, so it’s great for non-Material effects or when you’re building a custom interaction.
GestureDetector(
onLongPress: () {
_bioController.clear();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Bio cleared')),
);
},
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text('Long-press here to clear bio'),
),
)TextField vs TextFormField: choosing the right one
- TextField: best for simple input where you don’t need form-wide validation. You can still use a controller and
onChanged. - TextFormField: integrates with
Formand supportsvalidator, making it the default choice for multi-field screens that must validate before submit.
In most “submit a form” screens (login, signup, create task/profile), TextFormField is the practical default.
Controller management rules (important)
When to use a TextEditingController
- Use a controller when you need to read the value on submit, set an initial value programmatically, clear the field, or listen to changes.
- If you only need the value once and can rely on
onChangedto store it, a controller may be optional, but controllers are often clearer for forms.
Dispose resources you create
- If you create
TextEditingControllerorFocusNodein yourState, dispose them indispose(). - Do not dispose controllers you did not create (for example, if they are provided from outside).
Avoid creating controllers in build()
Creating controllers inside build() causes them to be recreated on every rebuild, which can reset text, break listeners, and leak resources. Create them once in the state (as shown) or in initState().
Basic input patterns you’ll reuse often
Pattern: Disable submit while processing
Use a boolean like _submitting to disable the button and prevent double submissions.
ElevatedButton(
onPressed: _submitting ? null : _submit,
child: _submitting ? const CircularProgressIndicator() : const Text('Save'),
)Pattern: Clear fields after success
After a successful submit, you may want to reset the form for another entry.
void _clearForm() {
_nameController.clear();
_emailController.clear();
_bioController.clear();
_formKey.currentState?.reset();
}Pattern: Validate on submit only (optional)
If you prefer not to show errors while typing, set autovalidateMode to disabled and validate only in _submit().
Form(
key: _formKey,
autovalidateMode: AutovalidateMode.disabled,
child: ...,
)