Lesson 26 of 51 intermediate

Forms & Text Input

Collecting User Data with Validation

Open interactive version (quiz + challenge)

Real-world analogy

A Form in Flutter is like a paper application form at a bank. Each TextFormField is a blank line where you write information (name, email, phone). Validation is like the bank teller checking your form before accepting it — 'You left the email blank!' or 'That phone number is too short!'. The GlobalKey is like the form's tracking number that lets the bank find and process your specific form.

What is it?

Forms in Flutter use the Form widget with a GlobalKey to group TextFormField inputs for collective validation. Each TextFormField has a validator function that returns an error string or null. TextEditingController provides programmatic access to field values. InputDecoration styles the fields with labels, icons, and borders.

Real-world relevance

In team_mvp_kit, the login screen, registration screen, and profile edit screen all use Form with TextFormField. The project defines reusable validator functions for email, password strength, and required fields. TextEditingControllers read values on submit and pass them to the authentication BLoC. FocusNodes provide smooth keyboard navigation between fields.

Key points

Code example

import 'package:flutter/material.dart';

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 _passwordCtrl = TextEditingController();
  final _passwordFocus = FocusNode();
  bool _obscurePassword = true;
  bool _isLoading = false;

  @override
  void dispose() {
    _emailCtrl.dispose();
    _passwordCtrl.dispose();
    _passwordFocus.dispose();
    super.dispose();
  }

  void _submit() {
    if (_formKey.currentState!.validate()) {
      setState(() => _isLoading = true);
      // Send _emailCtrl.text and _passwordCtrl.text to auth
      Future.delayed(const Duration(seconds: 2), () {
        setState(() => _isLoading = false);
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Login successful!')),
        );
      });
    }
  }

  @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: [
              TextFormField(
                controller: _emailCtrl,
                keyboardType: TextInputType.emailAddress,
                textInputAction: TextInputAction.next,
                onFieldSubmitted: (_) {
                  FocusScope.of(context).requestFocus(_passwordFocus);
                },
                decoration: const InputDecoration(
                  labelText: 'Email',
                  prefixIcon: Icon(Icons.email_outlined),
                  border: OutlineInputBorder(),
                ),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Email is required';
                  }
                  if (!value.contains('@')) {
                    return 'Enter a valid email';
                  }
                  return null;
                },
              ),
              const SizedBox(height: 16),
              TextFormField(
                controller: _passwordCtrl,
                focusNode: _passwordFocus,
                obscureText: _obscurePassword,
                textInputAction: TextInputAction.done,
                onFieldSubmitted: (_) => _submit(),
                decoration: InputDecoration(
                  labelText: 'Password',
                  prefixIcon: const Icon(Icons.lock_outlined),
                  border: const OutlineInputBorder(),
                  suffixIcon: IconButton(
                    icon: Icon(
                      _obscurePassword
                          ? Icons.visibility_off
                          : Icons.visibility,
                    ),
                    onPressed: () {
                      setState(() {
                        _obscurePassword = !_obscurePassword;
                      });
                    },
                  ),
                ),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Password is required';
                  }
                  if (value.length < 6) {
                    return 'Password must be at least 6 characters';
                  }
                  return null;
                },
              ),
              const SizedBox(height: 24),
              SizedBox(
                height: 48,
                child: ElevatedButton(
                  onPressed: _isLoading ? null : _submit,
                  child: _isLoading
                      ? const SizedBox(
                          height: 20,
                          width: 20,
                          child: CircularProgressIndicator(
                            strokeWidth: 2,
                          ),
                        )
                      : const Text('Login'),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Line-by-line walkthrough

  1. 1. Import the Material package
  2. 2.
  3. 3. Define LoginForm as a StatefulWidget since form state changes
  4. 4.
  5. 5.
  6. 6. Create the State class
  7. 7. GlobalKey to access the Form's state for validation
  8. 8. TextEditingController for the email field
  9. 9. TextEditingController for the password field
  10. 10. FocusNode to control password field focus
  11. 11. Boolean to toggle password visibility
  12. 12. Boolean to track loading state
  13. 13.
  14. 14. Dispose controllers and focus nodes to free memory
  15. 15.
  16. 16.
  17. 17.
  18. 18.
  19. 19. The submit method handles form submission
  20. 20. Check if all validators pass
  21. 21. Set loading to true to show progress indicator
  22. 22. Read email and password from controllers
  23. 23. Simulate a network delay of 2 seconds
  24. 24. Set loading back to false
  25. 25. Show a success SnackBar
  26. 26.
  27. 27.
  28. 28.
  29. 29.
  30. 30. Build method creates the UI
  31. 31. Scaffold with an AppBar titled Login
  32. 32. Body wrapped in 24px padding
  33. 33. Form widget groups all fields with the form key
  34. 34. Column stretches children to full width
  35. 35.
  36. 36. Email TextFormField
  37. 37. Attach the email controller
  38. 38. Set keyboard to email type with @ shortcut
  39. 39. Next action moves to password field
  40. 40. On submit, move focus to password field
  41. 41.
  42. 42. InputDecoration with label, email icon, and outlined border
  43. 43.
  44. 44.
  45. 45.
  46. 46. Validator: check empty then check for @ symbol
  47. 47. Return error strings or null for valid
  48. 48.
  49. 49.
  50. 50.
  51. 51. 16px gap between fields
  52. 52.
  53. 53. Password TextFormField
  54. 54. Attach password controller and focus node
  55. 55. obscureText hides the password text
  56. 56. Done action triggers submit
  57. 57. On keyboard done press, call submit
  58. 58. InputDecoration with label, lock icon, border
  59. 59.
  60. 60. suffixIcon toggles between visibility icons
  61. 61.
  62. 62. Toggle the obscure boolean on press
  63. 63.
  64. 64.
  65. 65.
  66. 66.
  67. 67.
  68. 68. Validator: check empty then check minimum length
  69. 69.
  70. 70.
  71. 71.
  72. 72.
  73. 73. 24px gap before the submit button
  74. 74.
  75. 75. 48px height button for comfortable tap target
  76. 76. Disable button while loading by setting onPressed to null
  77. 77. Show CircularProgressIndicator when loading
  78. 78.
  79. 79.
  80. 80.
  81. 81. Otherwise show Login text
  82. 82.
  83. 83.
  84. 84.
  85. 85.
  86. 86.

Spot the bug

class _FormState extends State<MyForm> {
  final _formKey = GlobalKey<FormState>();
  final _ctrl = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: TextFormField(controller: _ctrl),
    );
  }
}
Need a hint?
What is missing that could cause a memory leak?
Show answer
The TextEditingController is never disposed. When the widget is removed from the tree, the controller keeps listening in memory. Fix: add a dispose() method that calls _ctrl.dispose() and super.dispose() to free the controller's resources.

Explain like I'm 5

Imagine you are filling out a permission slip for a school trip. Each blank line is like a TextFormField where you write your name, your parent's phone number, and your allergies. The teacher (validator) checks every line before accepting the form. If you forgot to write your name, the teacher circles that line in red and says 'Please fill this in!' That red error message is exactly what TextFormField's validator does. The GlobalKey is like the teacher knowing which permission slip is yours among all the students.

Fun fact

Flutter's Form widget is inspired by HTML forms in web development. Just like HTML forms have an action and input elements with validation attributes, Flutter Forms group TextFormFields and validate them collectively. The GlobalKey concept is unique to Flutter though — it is how widgets talk to their state objects from outside!

Hands-on challenge

Build a registration form with four fields: Name (required, min 2 characters), Email (required, must contain @), Password (required, min 8 characters, must contain a number), and Confirm Password (must match password). Add FocusNode navigation between fields, a password visibility toggle, AutovalidateMode.onUserInteraction, and a submit button that shows a loading spinner while processing.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter & Dart