Flutter for Beginners: Handling User Input with Forms and Gestures

Capítulo 6

Estimated reading time: 7 minutes

+ Exercise

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 + TextFormField to 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:

Continue in our app.
  • Listen to the audio with the screen off.
  • Earn a certificate upon completion.
  • Over 5000 courses for you to explore!
Or continue reading below...
Download App

Download the app

  • null when 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 GestureDetector and call FocusScope.of(context).unfocus().
  • Next/Done navigation: use textInputAction and onFieldSubmitted to 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 Form and supports validator, 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 onChanged to store it, a controller may be optional, but controllers are often clearer for forms.

Dispose resources you create

  • If you create TextEditingController or FocusNode in your State, dispose them in dispose().
  • 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: ...,
)

Now answer the exercise about the content:

In a multi-field “Create Profile” form, which choice best matches the recommended approach for validating inputs and handling submission feedback?

You are right! Congratulations, now go to the next page

You missed! Try again.

A Form + TextFormField enables built-in validation via validator and form-wide validate() on submit. Dismissing the keyboard improves UX. Use SnackBar for non-blocking feedback and dialogs when acknowledgment or details are needed.

Next chapter

Flutter for Beginners: Navigation and Routing Between Screens

Arrow Right Icon
Free Ebook cover Flutter for Beginners: Build Your First Cross-Platform Apps from Scratch
50%

Flutter for Beginners: Build Your First Cross-Platform Apps from Scratch

New course

12 pages

Download the app to earn free Certification and listen to the courses in the background, even with the screen off.