Input & Forms #

Input and forms are the heart of almost every app that collects user data. Flutter provides two main widgets: TextField for basic text input, and TextFormField, which integrates with the Form system for coordinated validation. Understanding both — along with the TextEditingController, FocusNode, and InputDecoration ecosystem — lets you build forms that are functional, elegant, and easy to maintain.

TextField — Basic Text Input #

TextField is the fundamental widget for receiving text input from users. It’s highly customizable and supports various keyboard types, formatters, and decorations.

TextField(
  // Controller for reading/changing values programmatically
  controller: _controller,

  // The keyboard type that appears
  keyboardType: TextInputType.emailAddress,

  // The "done" button action on the keyboard
  textInputAction: TextInputAction.next,

  // Hide text (for passwords)
  obscureText: true,
  obscuringCharacter: '•',

  // Maximum characters
  maxLength: 100,

  // Auto expansion (for textareas)
  minLines: 1,
  maxLines: 5,

  // Visual decoration
  decoration: const InputDecoration(
    labelText: 'Email',
    hintText: '[email protected]',
    prefixIcon: Icon(Icons.email),
    border: OutlineInputBorder(),
  ),

  // Callbacks
  onChanged: (value) => print('Changed: $value'),
  onSubmitted: (value) => _handleSubmit(value),
)

TextEditingController — Programmatic Control #

TextEditingController lets you read the text value, change field content programmatically, and listen for value changes:

class _LoginState extends State<LoginScreen> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  void initState() {
    super.initState();
    // Initial content (pre-fill)
    _emailController.text = '[email protected]';

    // Listen for changes
    _emailController.addListener(() {
      // Called every time the text changes
      final isValid = _emailController.text.contains('@');
      setState(() => _emailValid = isValid);
    });
  }

  void _clearAll() {
    _emailController.clear();
    _passwordController.clear();
  }

  void _prefillAdmin() {
    // Set text programmatically
    _emailController.text = '[email protected]';
    // Move the cursor to the end
    _emailController.selection = TextSelection.fromPosition(
      TextPosition(offset: _emailController.text.length),
    );
  }

  String get _email => _emailController.text.trim();
  String get _password => _passwordController.text;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(controller: _emailController, ...),
        TextField(controller: _passwordController, ...),
        ElevatedButton(onPressed: _submit, child: const Text('Login')),
      ],
    );
  }

  @override
  void dispose() {
    // MUST dispose to prevent memory leaks
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }
}

TextField vs TextEditingController — When to Use Which? #

Use TextEditingController if:
  ✓ You need to read the text value when the submit button is pressed
  ✓ You need to change the field content programmatically (pre-fill, clear)
  ✓ You need to listen for value changes for custom logic
  ✓ You need to control the cursor position or selection

Use only the onChanged callback if:
  ✓ You only need to update state when the value changes
  ✓ You don't need to change the text programmatically
  ✓ Simpler code without a controller

FocusNode — Focus Management #

FocusNode enables programmatic control of keyboard focus between TextFields:

class _FormState extends State<FormScreen> {
  final _nameFocus    = FocusNode();
  final _emailFocus   = FocusNode();
  final _passwordFocus = FocusNode();

  @override
  void initState() {
    super.initState();
    // Listen for focus changes
    _emailFocus.addListener(() {
      if (!_emailFocus.hasFocus) {
        // Field lost focus -- validate
        _validateEmail();
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          focusNode: _nameFocus,
          textInputAction: TextInputAction.next,
          onSubmitted: (_) {
            // Move focus to the next field when Enter/Next is pressed
            FocusScope.of(context).requestFocus(_emailFocus);
          },
          decoration: const InputDecoration(labelText: 'Name'),
        ),
        TextField(
          focusNode: _emailFocus,
          textInputAction: TextInputAction.next,
          onSubmitted: (_) {
            FocusScope.of(context).requestFocus(_passwordFocus);
          },
          decoration: const InputDecoration(labelText: 'Email'),
        ),
        TextField(
          focusNode: _passwordFocus,
          textInputAction: TextInputAction.done,
          onSubmitted: (_) {
            // Hide the keyboard and submit the form
            _passwordFocus.unfocus();
            _submit();
          },
          decoration: const InputDecoration(labelText: 'Password'),
        ),
      ],
    );
  }

  @override
  void dispose() {
    _nameFocus.dispose();
    _emailFocus.dispose();
    _passwordFocus.dispose();
    super.dispose();
  }
}

Form and TextFormField — Coordinated Validation #

For forms with multiple fields and coordinated validation, use Form + TextFormField. Use a GlobalKey when creating the form. This gives a unique identity and enables form validation later.

This validation system works by flowing validation instructions from the Form widget to all child FormField widgets in a coordinated way. The validation flow can be illustrated through the following diagram:

flowchart TD
    Submit["Trigger: FormState.validate()"] --> ValidateAll["Iterate All Child FormFields"]
    ValidateAll --> CheckEach{"Call validator(value)"}
    CheckEach -->|Returns String| ShowError["Show Error Message on Screen"]
    CheckEach -->|Returns null| ValidState["Mark Input as Valid"]
    ShowError --> ReturnFalse["Returns false (Validation Failed)"]
    ValidState --> ReturnTrue["Returns true (Validation Succeeded)"]
class RegistrationForm extends StatefulWidget {
  const RegistrationForm({super.key});

  @override
  State<RegistrationForm> createState() => _RegistrationFormState();
}

class _RegistrationFormState extends State<RegistrationForm> {
  // GlobalKey for accessing FormState
  final _formKey = GlobalKey<FormState>();

  // Form state
  String _name = '';
  String _email = '';
  String _password = '';
  bool _agree = false;

  void _submit() {
    // Validate all fields at once
    if (_formKey.currentState!.validate()) {
      // Save values from all fields
      _formKey.currentState!.save();

      // Process the data
      _register(_name, _email, _password);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          TextFormField(
            decoration: const InputDecoration(
              labelText: 'Full Name',
              prefixIcon: Icon(Icons.person),
            ),
            validator: (value) {
              if (value == null || value.trim().isEmpty) {
                return 'Name cannot be empty';
              }
              if (value.trim().length < 3) {
                return 'Name must be at least 3 characters';
              }
              return null; // valid
            },
            onSaved: (value) => _name = value!.trim(),
          ),
          const SizedBox(height: 16),
          TextFormField(
            keyboardType: TextInputType.emailAddress,
            decoration: const InputDecoration(
              labelText: 'Email',
              prefixIcon: Icon(Icons.email),
            ),
            validator: (value) {
              if (value == null || value.isEmpty) {
                return 'Email cannot be empty';
              }
              final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+');
              if (!emailRegex.hasMatch(value)) {
                return 'Invalid email format';
              }
              return null;
            },
            onSaved: (value) => _email = value!,
          ),
          const SizedBox(height: 16),
          TextFormField(
            obscureText: true,
            decoration: const InputDecoration(
              labelText: 'Password',
              prefixIcon: Icon(Icons.lock),
            ),
            validator: (value) {
              if (value == null || value.isEmpty) {
                return 'Password cannot be empty';
              }
              if (value.length < 8) {
                return 'Password must be at least 8 characters';
              }
              if (!value.contains(RegExp(r'[A-Z]'))) {
                return 'Password must contain an uppercase letter';
              }
              return null;
            },
            onSaved: (value) => _password = value!,
          ),
          const SizedBox(height: 24),
          ElevatedButton(
            onPressed: _submit,
            child: const Text('Register'),
          ),
        ],
      ),
    );
  }
}

AutovalidateMode — When Validation Happens #

AutovalidateMode controls when TextFormField validation takes place.

// 1. disabled (default): validation ONLY when _formKey.currentState!.validate() is called
TextFormField(
  autovalidateMode: AutovalidateMode.disabled,
  validator: _validateEmail,
)

// 2. onUserInteraction: validation after the user starts interacting
// Error appears when the user types and leaves the field
TextFormField(
  autovalidateMode: AutovalidateMode.onUserInteraction,
  validator: _validateEmail,
)

// 3. always: continuous validation, even before the user touches the field
// Use carefully -- can show errors too early
TextFormField(
  autovalidateMode: AutovalidateMode.always,
  validator: _validateEmail,
)

// Recommended pattern:
// Start with disabled, switch to onUserInteraction after the first submit
class _FormState extends State<MyForm> {
  bool _submitted = false;

  AutovalidateMode get _autovalidateMode => _submitted
      ? AutovalidateMode.onUserInteraction
      : AutovalidateMode.disabled;
}

InputDecoration — Visual Customization #

InputDecoration controls all visual aspects of TextField and TextFormField:

TextFormField(
  decoration: InputDecoration(
    // Floating label when focused
    labelText: 'Email',
    labelStyle: const TextStyle(color: Colors.blue),

    // Placeholder when empty
    hintText: '[email protected]',
    hintStyle: TextStyle(color: Colors.grey.shade400),

    // Helper text below the field
    helperText: 'Use an active email',

    // Icons
    prefixIcon: const Icon(Icons.email),
    suffixIcon: IconButton(
      icon: const Icon(Icons.clear),
      onPressed: () => _controller.clear(),
    ),

    // Prefix/suffix text inside the field
    prefixText: '+62 ',
    suffixText: '.com',

    // Border styles
    border: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
    ),
    enabledBorder: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
      borderSide: BorderSide(color: Colors.grey.shade300),
    ),
    focusedBorder: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
      borderSide: const BorderSide(color: Colors.blue, width: 2),
    ),
    errorBorder: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
      borderSide: const BorderSide(color: Colors.red),
    ),

    // Background
    filled: true,
    fillColor: Colors.grey.shade50,

    // Content padding
    contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  ),
)

Keyboard Types and TextInputAction #

// keyboardType -- which keyboard appears
TextField(keyboardType: TextInputType.text)         // default: regular keyboard
TextField(keyboardType: TextInputType.emailAddress)  // @ and .com
TextField(keyboardType: TextInputType.number)        // numbers only
TextField(keyboardType: TextInputType.phone)         // phone keypad
TextField(keyboardType: TextInputType.datetime)      // date/time
TextField(keyboardType: TextInputType.url)           // URL keyboard
TextField(keyboardType: TextInputType.multiline)     // Enter = new line

// textInputAction -- action button in the keyboard's bottom-right corner
TextField(textInputAction: TextInputAction.next)     // "Next" → move focus
TextField(textInputAction: TextInputAction.done)     // "Done" → close keyboard
TextField(textInputAction: TextInputAction.search)   // "Search" → magnifying glass icon
TextField(textInputAction: TextInputAction.send)     // "Send"
TextField(textInputAction: TextInputAction.go)       // "Go"

InputFormatters — Restrict and Format Input #

InputFormatter lets you filter or format text in real time as the user types:

import 'package:flutter/services.dart';

// Flutter built-in formatters
TextField(
  inputFormatters: [
    // Only allow digits
    FilteringTextInputFormatter.digitsOnly,

    // Only letters (no spaces, digits, symbols)
    FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z]')),

    // Reject certain characters
    FilteringTextInputFormatter.deny(RegExp(r'[<>]')),

    // Length limit
    LengthLimitingTextInputFormatter(10),
  ],
)

// Custom formatter -- phone number formatting
class PhoneInputFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    final text = newValue.text.replaceAll(RegExp(r'[^0-9]'), '');

    // Format: 0812-3456-7890
    final buffer = StringBuffer();
    for (int i = 0; i < text.length; i++) {
      if (i == 4 || i == 8) buffer.write('-');
      buffer.write(text[i]);
    }

    final formatted = buffer.toString();
    return TextEditingValue(
      text: formatted,
      selection: TextSelection.collapsed(offset: formatted.length),
    );
  }
}

// Using the custom formatter
TextField(
  inputFormatters: [
    FilteringTextInputFormatter.digitsOnly,
    LengthLimitingTextInputFormatter(12),
    PhoneInputFormatter(),
  ],
)

Other Input Widgets #

// Checkbox
CheckboxListTile(
  title: const Text('I agree to the terms and conditions'),
  value: _agree,
  onChanged: (value) => setState(() => _agree = value!),
)

// Switch
SwitchListTile(
  title: const Text('Push notifications'),
  subtitle: const Text('Receive notifications from us'),
  value: _notificationsEnabled,
  onChanged: (value) => setState(() => _notificationsEnabled = value),
)

// Radio Buttons
Column(
  children: [
    RadioListTile<String>(
      title: const Text('Male'),
      value: 'male',
      groupValue: _gender,
      onChanged: (value) => setState(() => _gender = value!),
    ),
    RadioListTile<String>(
      title: const Text('Female'),
      value: 'female',
      groupValue: _gender,
      onChanged: (value) => setState(() => _gender = value!),
    ),
  ],
)

// Slider
Slider(
  value: _value,
  min: 0,
  max: 100,
  divisions: 10,
  label: '${_value.round()}',
  onChanged: (v) => setState(() => _value = v),
)

// DropdownButton
DropdownButtonFormField<String>(
  decoration: const InputDecoration(labelText: 'City'),
  value: _selectedCity,
  items: ['Jakarta', 'Bandung', 'Surabaya']
      .map((city) => DropdownMenuItem(value: city, child: Text(city)))
      .toList(),
  onChanged: (value) => setState(() => _selectedCity = value),
  validator: (value) => value == null ? 'Select a city' : null,
)

Reusable Form Patterns #

Create a custom form field widget reusable across the whole app:

class AppTextFormField extends StatelessWidget {
  final String label;
  final String? hint;
  final IconData? prefixIcon;
  final bool obscureText;
  final TextInputType? keyboardType;
  final TextInputAction textInputAction;
  final String? Function(String?)? validator;
  final void Function(String?)? onSaved;
  final void Function(String)? onChanged;
  final TextEditingController? controller;
  final FocusNode? focusNode;
  final VoidCallback? onEditingComplete;

  const AppTextFormField({
    super.key,
    required this.label,
    this.hint,
    this.prefixIcon,
    this.obscureText = false,
    this.keyboardType,
    this.textInputAction = TextInputAction.next,
    this.validator,
    this.onSaved,
    this.onChanged,
    this.controller,
    this.focusNode,
    this.onEditingComplete,
  });

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      controller: controller,
      focusNode: focusNode,
      obscureText: obscureText,
      keyboardType: keyboardType,
      textInputAction: textInputAction,
      onEditingComplete: onEditingComplete,
      validator: validator,
      onSaved: onSaved,
      onChanged: onChanged,
      decoration: InputDecoration(
        labelText: label,
        hintText: hint,
        prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(8),
        ),
        filled: true,
        fillColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
      ),
    );
  }
}

// Usage -- clean and consistent across the whole app
AppTextFormField(
  label: 'Email',
  hint: '[email protected]',
  prefixIcon: Icons.email,
  keyboardType: TextInputType.emailAddress,
  validator: Validators.email,
  onSaved: (v) => _email = v!,
)

Summary #

  • TextField is a highly customizable basic input widget. Use it with TextEditingController for programmatic control and FocusNode for focus management between fields.
  • TextFormField integrates with Form and GlobalKey<FormState> for coordinated validation — ideal for forms with multiple fields.
  • Validators return a String containing an error message if the input is invalid, or null if valid.
  • AutovalidateMode controls when validation happens: disabled (only when validate() is called), onUserInteraction (after the user interacts), or always.
  • InputDecoration controls all visual aspects — labels, hints, icons, borders, backgrounds, and error styles.
  • InputFormatter filters or formats input in real time. Use FilteringTextInputFormatter for common cases and create custom ones for formats like phone numbers.
  • Use textInputAction to control the keyboard action button (next, done, search) and keyboardType for the appropriate keyboard type.
  • Create reusable custom form field widgets for visual consistency and reduced code duplication across the app.
  • Always dispose TextEditingController and FocusNode in dispose() to prevent memory leaks.

← Previous: Scrolling   Next: Animation →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact