Module 9: User Input and Forms

Collecting Data Without Losing Your Mind

Building UI Screens

A form with text fields and validation messages Behind every sign-up screen, every checkout page, and every settings panel is a developer who figured out form validation.

🎯

Teach: How to collect and validate user input using TextField, Form, TextFormField, and Flutter's built-in input widgets. See: A registration form with real validation, error messages appearing under invalid fields, and a settings page with switches, sliders, and dropdowns. Feel: Confident that you can build any data-entry screen with proper validation.

🎙️

Up until now, you've been building screens that display things. Today, you flip the direction: the USER puts data IN. Text fields, dropdowns, checkboxes, sliders -- these are the widgets that turn your app from a display into a conversation. And the most important part of that conversation? Validation. Because users will type garbage into your carefully labeled fields, leave required fields blank, and put phone numbers where emails should go. Flutter gives you a validation framework that catches all of this before it causes problems. Let's learn to use it.


TextField and TextEditingController

🎯

Teach: TextField is the text input widget, and TextEditingController gives you programmatic access to read, change, and clear whatever the user typed. See: A TextField with decoration, a controller that reads the input on button press, and real-time reactions to every keystroke. Feel: That text input in Flutter is straightforward once you understand the controller pattern.

🎙️

TextField is the workhorse of user input. It's the text box where people type things. But here's the key insight: you don't just plop a TextField on screen and hope for the best. You attach a TextEditingController to it, which gives you programmatic access to whatever the user typed. Think of the controller as the TextField's brain -- it knows what's in the box and lets you read it, change it, or clear it from your code.

A basic TextField:

TextField(
  decoration: InputDecoration(
    labelText: 'Name',
    hintText: 'Enter your name',
    border: OutlineInputBorder(),
    prefixIcon: Icon(Icons.person),
  ),
)

But how do you READ what the user typed? You need a TextEditingController:

class _MyFormState extends State<MyForm> {
  final _nameController = TextEditingController();

  @override
  void dispose() {
    _nameController.dispose();  // ALWAYS dispose controllers!
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          controller: _nameController,
          decoration: InputDecoration(
            labelText: 'Name',
            border: OutlineInputBorder(),
          ),
        ),
        SizedBox(height: 16),
        ElevatedButton(
          onPressed: () {
            print('Name: ${_nameController.text}');
          },
          child: Text('Submit'),
        ),
      ],
    );
  }
}

Why You Must Dispose Controllers

Controllers hold references to resources. If you don't dispose them in your State's dispose() method, you get memory leaks. Flutter will actually warn you about this in debug mode. Make it a habit: if you create a controller, dispose it.

Responding to Changes in Real Time

You can react to every keystroke using onChanged or a listener:

// Option 1: onChanged callback
TextField(
  onChanged: (value) {
    setState(() {
      _greeting = 'Hello, $value!';
    });
  },
)

// Option 2: Controller listener (set up in initState)
@override
void initState() {
  super.initState();
  _nameController.addListener(() {
    setState(() {
      _greeting = 'Hello, ${_nameController.text}!';
    });
  });
}

Both approaches work. onChanged is simpler for one-off use. Controller listeners are better when multiple widgets need to react to the same field.


Form and TextFormField: Validation That Just Works

🎯

Teach: The Form widget groups multiple input fields and provides collective validation -- one call validates everything, and error messages appear automatically. See: A form where hitting "Submit" highlights every invalid field with a red error message underneath. Feel: That Flutter's validation framework eliminates the tedious work of checking each field manually.

The Form + GlobalKey Pattern

Form is a wrapper that groups fields and coordinates validation. You need a GlobalKey<FormState> to talk to it:

class _RegistrationState extends State<Registration> {
  final _formKey = GlobalKey<FormState>();
  String _email = '';

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            decoration: InputDecoration(labelText: 'Email'),
            validator: (value) {
              if (value == null || value.isEmpty) {
                return 'Email is required';
              }
              if (!value.contains('@')) {
                return 'Enter a valid email';
              }
              return null;  // null means valid!
            },
            onSaved: (value) {
              _email = value!;
            },
          ),
          SizedBox(height: 16),
          ElevatedButton(
            onPressed: () {
              if (_formKey.currentState!.validate()) {
                _formKey.currentState!.save();
                // All fields valid -- process the data
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('Submitted: $_email')),
                );
              }
            },
            child: Text('Submit'),
          ),
        ],
      ),
    );
  }
}

How This Works Step by Step

  1. Form wraps all your input fields
  2. Each TextFormField has a validator function that returns an error message (or null if valid)
  3. When you call _formKey.currentState!.validate():
  4. Flutter runs EVERY validator in the form
  5. Invalid fields display their error message in red below the field
  6. The method returns true only if ALL validators return null
  7. When you call _formKey.currentState!.save():
  8. Flutter runs every onSaved callback
  9. You use these to store the final values
🎙️

Here's what makes this pattern so nice: you don't write any "check each field" logic. You don't manually show error messages. You don't track which fields are valid and which aren't. You define the rules on each field, call validate() once, and Flutter handles the rest. The error messages appear, the valid flag is computed, and you just check one boolean.

TextFormField vs. TextField

TextFormField is a TextField that works with Form. It adds: - A validator function - An onSaved callback - Automatic error message display

If you're inside a Form, use TextFormField. If you're just collecting a single value without validation, plain TextField is fine.


Writing Good Validators

🎯

Teach: Validators are simple functions that return null for valid input and an error message string for invalid input -- covering required fields, length, format, and cross-field comparison. See: Validator patterns for required fields, minimum length, email format, phone numbers, password strength, and confirm-password matching. Feel: That writing validators is formulaic and you can handle any real-world validation requirement.

Validators are just functions that return a String?. Return null for valid, return an error message for invalid:

// Required field
validator: (value) {
  if (value == null || value.isEmpty) {
    return 'This field is required';
  }
  return null;
},

// Minimum length
validator: (value) {
  if (value == null || value.length < 8) {
    return 'Must be at least 8 characters';
  }
  return null;
},

// Email format
validator: (value) {
  if (value == null || !value.contains('@') || !value.contains('.')) {
    return 'Enter a valid email address';
  }
  return null;
},

// Phone number (10 digits)
validator: (value) {
  if (value == null || !RegExp(r'^\d{10}$').hasMatch(value)) {
    return 'Enter a 10-digit phone number';
  }
  return null;
},

// Password strength
validator: (value) {
  if (value == null || value.length < 8) {
    return 'Password must be at least 8 characters';
  }
  if (!value.contains(RegExp(r'[A-Z]'))) {
    return 'Must contain an uppercase letter';
  }
  if (!value.contains(RegExp(r'[0-9]'))) {
    return 'Must contain a digit';
  }
  return null;
},

Confirm Password: Comparing Two Fields

When you need one field to match another, use a TextEditingController for the first field:

final _passwordController = TextEditingController();

// Password field
TextFormField(
  controller: _passwordController,
  obscureText: true,
  decoration: InputDecoration(labelText: 'Password'),
  validator: (value) {
    if (value == null || value.length < 8) {
      return 'Minimum 8 characters';
    }
    return null;
  },
),

// Confirm password field
TextFormField(
  obscureText: true,
  decoration: InputDecoration(labelText: 'Confirm Password'),
  validator: (value) {
    if (value != _passwordController.text) {
      return 'Passwords do not match';
    }
    return null;
  },
),
💡

Validators return null for valid, a String for invalid. The string IS the error message. Flutter displays it automatically below the field in red. You don't need to manage error state yourself.

🎙️

I want you to notice the shape of these validators. They are all small. They check one thing. They return either null or a message. Resist the urge to write one giant validator that checks five conditions at once. Instead, write clear checks in order of severity — null first, then length, then format, then custom rules — and return the first failure. Users see the error that matters most right now, fix it, and move on. They don't want to see "enter a valid email" when the field is empty — they want to see "email is required." Good validators tell the truth, in order.


Input Widgets Beyond Text

🎯

Teach: DropdownButton, Switch, Checkbox, and Slider all follow the same pattern: hold a value, call a callback on change, update state with setState. See: Each widget type in action -- a dropdown menu, a toggle switch, a checkbox, and a draggable slider with a live preview. Feel: That all input widgets work the same way, so learning one means you already know how the others work.

🎙️

Not every input is text. Sometimes you need the user to pick from a list, flip a switch, check a box, or drag a slider. Flutter has dedicated widgets for all of these, and they all follow the same pattern: they hold a current value, and they call a callback when the user changes it. You update your state variable in that callback, call setState, and the widget reflects the new value. Same dance as always.

Presents a list of options in a menu:

String _selectedRole = 'Student';
final _roles = ['Student', 'Teacher', 'Admin'];

DropdownButton<String>(
  value: _selectedRole,
  items: _roles.map((role) {
    return DropdownMenuItem(value: role, child: Text(role));
  }).toList(),
  onChanged: (value) {
    setState(() {
      _selectedRole = value!;
    });
  },
)

For use inside a Form, use DropdownButtonFormField which adds validation support:

DropdownButtonFormField<String>(
  value: _selectedRole,
  decoration: InputDecoration(
    labelText: 'Role',
    border: OutlineInputBorder(),
  ),
  items: _roles.map((role) {
    return DropdownMenuItem(value: role, child: Text(role));
  }).toList(),
  validator: (value) {
    if (value == null || value.isEmpty) {
      return 'Please select a role';
    }
    return null;
  },
  onChanged: (value) {
    setState(() {
      _selectedRole = value!;
    });
  },
)

Switch

A toggle between on and off:

bool _notificationsOn = true;

SwitchListTile(
  title: Text('Notifications'),
  subtitle: Text('Receive push notifications'),
  value: _notificationsOn,
  onChanged: (value) {
    setState(() {
      _notificationsOn = value;
    });
  },
)

SwitchListTile combines a Switch with a ListTile for a complete labeled row. Use plain Switch when you want to position it yourself.

Checkbox

A checkable box:

bool _agreeToTerms = false;

CheckboxListTile(
  title: Text('I agree to the Terms and Conditions'),
  value: _agreeToTerms,
  onChanged: (value) {
    setState(() {
      _agreeToTerms = value!;
    });
  },
)

Slider

A draggable value selector:

double _fontSize = 16.0;

Column(
  children: [
    Slider(
      value: _fontSize,
      min: 12,
      max: 36,
      divisions: 12,
      label: _fontSize.round().toString(),
      onChanged: (value) {
        setState(() {
          _fontSize = value;
        });
      },
    ),
    Text(
      'Preview text',
      style: TextStyle(fontSize: _fontSize),
    ),
  ],
)

The divisions parameter snaps the slider to discrete values. The label appears in a tooltip above the thumb while dragging.


The Form Submission Pattern

🎯

Teach: A complete form submission follows a predictable sequence: validate all fields, check non-form conditions, save values, and respond to the user. See: The full pattern from button press to success message. Feel: That form handling in Flutter is methodical and reliable.

Here's the complete pattern for submitting a form:

ElevatedButton(
  onPressed: () {
    // Step 1: Validate all TextFormFields
    if (_formKey.currentState!.validate()) {

      // Step 2: Check non-form conditions (checkbox, etc.)
      if (!_agreeToTerms) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('You must agree to the terms')),
        );
        return;
      }

      // Step 3: Save all field values
      _formKey.currentState!.save();

      // Step 4: Do something with the data
      print('Name: $_name, Email: $_email, Role: $_role');

      // Step 5: Give feedback to the user
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Registration successful!')),
      );
    }
  },
  child: Text('Submit'),
)
🎙️

This pattern is worth memorizing. Validate, check extra conditions, save, process, and give feedback. Every form you ever build will follow this exact sequence. The only things that change are the fields and the processing logic. The structure stays the same.


There Are No Dumb Questions

🎯

Teach: Answers to the most common form and input questions -- TextField vs. TextFormField, GlobalKey purpose, auto-validation, controller disposal, and password visibility toggles. See: Clear answers with code snippets that resolve typical beginner confusion about forms. Feel: That these are universal questions and the answers are simple once you see them.

Q: What's the difference between TextField and TextFormField?

A: TextFormField is a TextField that participates in a Form. It adds validator and onSaved callbacks. Use TextFormField inside a Form. Use TextField for standalone input fields that don't need group validation.

Q: Why do I need a GlobalKey for the Form?

A: The GlobalKey<FormState> gives you a handle to the Form's internal state. Without it, you can't call validate() or save(). Think of it as a remote control for the form.

Q: Can I validate a form field as the user types, not just on submit?

A: Yes. Set autovalidateMode: AutovalidateMode.onUserInteraction on the TextFormField. This runs the validator after every change. Be careful though -- showing errors while someone is still typing can feel aggressive.

Q: What happens if I don't dispose a TextEditingController?

A: Memory leak. The controller holds references to resources and listeners. Flutter's debug mode will print a warning. Always dispose controllers in your State's dispose() method.

Q: Can I programmatically clear a form?

A: Yes. Call _formKey.currentState!.reset() to clear all fields and remove any validation errors. You can also clear individual fields by calling .clear() on their controllers.

Q: How do I make a password field with a "show/hide" toggle?

A: Use a bool _obscurePassword = true state variable. Set obscureText: _obscurePassword on the field. Add a suffixIcon that toggles the bool:

TextFormField(
  obscureText: _obscurePassword,
  decoration: InputDecoration(
    labelText: 'Password',
    suffixIcon: IconButton(
      icon: Icon(
        _obscurePassword ? Icons.visibility_off : Icons.visibility,
      ),
      onPressed: () {
        setState(() {
          _obscurePassword = !_obscurePassword;
        });
      },
    ),
  ),
)
🎙️

The autovalidate question deserves a note. Turning on AutovalidateMode.onUserInteraction sounds helpful, but in practice it can be annoying — the user types one character of an email, sees a "not a valid email" error, and wonders if the form is broken. The gentler pattern is to validate only on submit, and then enable auto-validation AFTER the first failed submit so the user sees errors update as they fix them. Forms are as much about user experience as they are about data correctness. Make the errors appear when they help, not when they pester.


Sharpen Your Pencil: Registration Form

🎯

Teach: How to build a complete multi-field form with Form, GlobalKey, TextFormField validators, a dropdown, a checkbox, and proper submission handling. See: A registration form with name, email, phone, password, confirm password, role dropdown, and terms checkbox -- all with validation. Feel: That you can build any real-world registration or sign-up screen from scratch.

Build a complete registration form with full validation.

Requirements

  1. Create a StatefulWidget called RegistrationForm
  2. Use a Form with a GlobalKey<FormState>
  3. Create and dispose TextEditingController instances for each text field
  4. Fields inside SingleChildScrollView > Padding > Column:
  5. Full Name -- required, minimum 2 characters
  6. Email -- required, must contain @ and .
  7. Phone Number -- required, exactly 10 digits
  8. Password -- required, minimum 8 characters, must contain at least one uppercase letter and one digit. Use obscureText: true
  9. Confirm Password -- must match password field
  10. A DropdownButtonFormField for Role: "Student", "Developer", "Designer", "Manager" (required)
  11. A CheckboxListTile for terms agreement. Form cannot submit without it -- show a SnackBar error
  12. A "Register" ElevatedButton that validates, saves, and shows a success SnackBar
  13. Each field: InputDecoration with labelText, hintText, OutlineInputBorder(), and a prefixIcon
  14. Wrap in Scaffold with AppBar titled "Registration"
🎙️

This is a meaty exercise, and I want you to treat it that way. A registration form is the most common piece of production code you'll write in your career — every app has one. Pay attention to the controller lifecycle: initialize in the field, dispose in the dispose method. Pay attention to the confirm-password pattern — the second field has to look at the first field's controller. And pay attention to the terms checkbox — the canonical pattern is to fail form submission with a snackbar if it's unchecked, because the Form validator system is designed for text inputs. Non-text requirements need explicit checks in your submit handler.


Sharpen Your Pencil: Settings Page

🎯

Teach: How to build a settings screen combining TextField, SwitchListTile, Slider, CheckboxListTile, and DropdownButton with live previews. See: A settings page with profile, appearance, and preferences sections, each using different input widget types. Feel: Confident that you can build any settings or preferences screen an app might need.

Build a settings page using a variety of input widgets.

Requirements

  1. Create a StatefulWidget called SettingsPage
  2. State variables:
  3. String _username (default: "User")
  4. bool _darkMode (default: false)
  5. bool _notifications (default: true)
  6. bool _autoSave (default: false)
  7. double _fontSize (default: 16.0)
  8. String _language (default: "English")

  9. Build in a ListView with these sections:

Profile Section: - TextField for username with a controller (init text in initState, dispose in dispose) - Live preview: "Hello, [username]!" updates as user types

Appearance Section: - SwitchListTile for Dark Mode - Slider for Font Size (range 12-32, 10 divisions, show current value as label) - Preview text rendered at the selected font size

Preferences Section: - SwitchListTile for Notifications - CheckboxListTile for Auto-Save - DropdownButton for Language: "English", "Spanish", "French", "German", "Japanese"

  1. Section headers with bold text and visual separation (dividers or background color)
  2. Wrap in Scaffold with AppBar titled "Settings"
🎙️

Settings pages are where you get a chance to use every input widget in one place. Switches for binary toggles. Sliders for continuous ranges. Dropdowns for small enumerations. CheckboxListTile when you want a label and a hit target. Notice the live preview requirements — as the user changes the font size slider, a preview text should resize in real time. That's what makes settings feel responsive instead of mysterious. When in doubt, show the user what their choice will look like before they commit.

🔄

Where this fits: Forms and user input are how your app collects data from the real world. The Form + TextFormField + validator pattern is one of the most reused patterns in all of Flutter development. The settings page pattern (switches, sliders, dropdowns) appears in virtually every app. Master these and you can build any data-entry screen.


📝 Module Quiz

Test what you learned. 45 seconds per question. Passing score is set per module. Your best attempt is saved in your browser so you can track progress -- nothing is sent to a server.

Wrapping Up

🎯

Teach: A summary of the complete input toolkit -- TextField, Form, validators, and non-text input widgets -- and how they fit with everything learned so far. See: The full picture: layout, content, state, lists, and now user input forming a complete foundation for building real apps. Feel: That you have all the fundamental pieces to build any interactive, data-driven mobile screen.

🎙️

You've completed the input side of Flutter. TextField and TextEditingController give you text input with full programmatic control. Form and TextFormField give you group validation that catches errors before they cause problems. DropdownButton, Switch, Checkbox, and Slider cover every other kind of input you'll commonly need. The validator pattern -- return null for valid, return an error message for invalid -- is elegant and consistent across all form fields. Combined with what you've learned in previous modules about layout, content, state, and lists, you now have the complete toolkit for building real, interactive, data-driven mobile screens.

💡

The form validation contract: validators return null for valid, a String for invalid. Call validate() once and Flutter checks everything, displays errors, and gives you a single boolean. This pattern handles 95% of real-world form validation needs.

1 / 1