🏠 Home / Hub

Flutter 05 — Forms & Input

Collect user input with TextField, TextFormField, Form validation, and specialized input widgets.

1. TextField Widget

TextField is the basic text input widget. Use decoration to add labels, hints, borders, icons, and error messages.

TextField(
  // Controller to read and control the value
  controller: _nameController,

  // Keyboard type
  keyboardType: TextInputType.emailAddress,
  // Options: text | number | phone | emailAddress | url | multiline | datetime

  // Action button on keyboard
  textInputAction: TextInputAction.next,  // shows "next" key
  // Options: done | next | go | search | send

  // Decoration (visual styling)
  decoration: const InputDecoration(
    labelText: 'Email Address',
    hintText: 'Enter your email',
    helperText: 'We will never share your email',
    prefixIcon: Icon(Icons.email),
    suffixIcon: Icon(Icons.clear),
    border: OutlineInputBorder(),
    focusedBorder: OutlineInputBorder(
      borderSide: BorderSide(color: Colors.blue, width: 2),
    ),
    filled: true,
    fillColor: Colors.grey,
  ),

  // Callbacks
  onChanged: (value) => print('Typing: $value'),
  onSubmitted: (value) => print('Submitted: $value'),
  onTap: () => print('Field tapped'),

  // Input restrictions
  maxLength: 100,              // shows character counter
  maxLines: 1,                 // 1 = single line, null = unlimited
  minLines: 3,                 // multiline minimum height
  obscureText: true,           // password field (hides characters)
  readOnly: true,              // can focus but not edit
  enabled: false,              // completely disabled (greyed out)

  // Auto-correct and capitalization
  autocorrect: false,
  enableSuggestions: false,
  textCapitalization: TextCapitalization.sentences,
)

2. TextEditingController

A TextEditingController lets you read the current text, set text programmatically, clear the field, and listen to changes.

class MyFormState extends State<MyForm> {
  // Create controllers — one per TextField
  final TextEditingController _nameController = TextEditingController();
  final TextEditingController _emailController = TextEditingController();

  @override
  void initState() {
    super.initState();
    // Pre-fill a field
    _nameController.text = 'Alice';

    // Listen to changes without onChanged callback
    _nameController.addListener(() {
      print('Name changed: ${_nameController.text}');
    });
  }

  @override
  void dispose() {
    // ALWAYS dispose controllers to prevent memory leaks
    _nameController.dispose();
    _emailController.dispose();
    super.dispose();
  }

  void _submit() {
    final name = _nameController.text.trim();
    final email = _emailController.text.trim();
    print('Name: $name, Email: $email');
  }

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

  void _setName(String value) {
    _nameController.text = value;
    // Move cursor to end after setting text
    _nameController.selection = TextSelection.fromPosition(
      TextPosition(offset: _nameController.text.length),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(controller: _nameController, decoration: const InputDecoration(labelText: 'Name')),
        TextField(controller: _emailController, decoration: const InputDecoration(labelText: 'Email')),
        ElevatedButton(onPressed: _submit, child: const Text('Submit')),
        TextButton(onPressed: _clearAll, child: const Text('Clear')),
      ],
    );
  }
}
Always dispose controllers in dispose(). Forgetting to dispose causes memory leaks because the controller keeps listening after the widget is removed.

3. Form Widget & GlobalKey

Form is a container that groups TextFormField widgets. It uses a GlobalKey<FormState> to validate and save all fields at once.

class LoginScreen extends StatefulWidget {
  const LoginScreen({super.key});
  @override
  State<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  // The key gives us programmatic access to the Form's state
  final _formKey = GlobalKey<FormState>();

  // Store values from form fields
  String _email = '';
  String _password = '';

  void _submit() {
    // Triggers all validators — returns true if all pass
    if (_formKey.currentState!.validate()) {
      // Save all TextFormField values into their onSaved callbacks
      _formKey.currentState!.save();
      print('Login: $_email / $_password');
    }
  }

  void _reset() {
    _formKey.currentState!.reset();   // clears all fields and errors
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(/* see next section */),
          TextFormField(/* see next section */),
          ElevatedButton(onPressed: _submit, child: const Text('Login')),
          TextButton(onPressed: _reset, child: const Text('Reset')),
        ],
      ),
    );
  }
}

4. TextFormField with Validator

TextFormField is like TextField but designed to work inside a Form. It adds validator and onSaved callbacks.

// Email field
TextFormField(
  decoration: const InputDecoration(
    labelText: 'Email',
    prefixIcon: Icon(Icons.email),
    border: OutlineInputBorder(),
  ),
  keyboardType: TextInputType.emailAddress,
  textInputAction: TextInputAction.next,

  // Validator: return an error string or null if valid
  validator: (value) {
    if (value == null || value.trim().isEmpty) {
      return 'Email is required';
    }
    final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+$');
    if (!emailRegex.hasMatch(value.trim())) {
      return 'Enter a valid email address';
    }
    return null;   // null means valid
  },

  // Called when formKey.currentState!.save() is invoked
  onSaved: (value) => _email = value!.trim(),
)

// Password field
TextFormField(
  decoration: InputDecoration(
    labelText: 'Password',
    prefixIcon: const Icon(Icons.lock),
    border: const OutlineInputBorder(),
    suffixIcon: IconButton(
      icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
      onPressed: () => setState(() => _obscure = !_obscure),
    ),
  ),
  obscureText: _obscure,
  textInputAction: TextInputAction.done,
  onFieldSubmitted: (_) => _submit(),
  validator: (value) {
    if (value == null || value.isEmpty) return 'Password is required';
    if (value.length < 8) return 'Password must be at least 8 characters';
    return null;
  },
  onSaved: (value) => _password = value!,
)

5. Form Validation Flow

// STEP 1: Create the key
final _formKey = GlobalKey<FormState>();

// STEP 2: Wrap fields in Form with the key
Form(
  key: _formKey,
  autovalidateMode: AutovalidateMode.onUserInteraction,
  // AutovalidateMode options:
  //   disabled           — only validate on manual call (default)
  //   onUserInteraction  — validate as user types after first interaction
  //   always             — validate constantly
  child: Column(children: [ /* TextFormField widgets */ ]),
)

// STEP 3: Validate on submit
void _onSubmit() {
  // Runs all validators — returns true if all return null
  final isValid = _formKey.currentState!.validate();

  if (isValid) {
    _formKey.currentState!.save();    // calls all onSaved callbacks
    // proceed with the data
    _doLogin(_email, _password);
  }
}

// Common validator patterns:
String? requiredValidator(String? value) {
  if (value == null || value.trim().isEmpty) return 'This field is required';
  return null;
}

String? emailValidator(String? value) {
  if (value == null || value.isEmpty) return 'Required';
  if (!RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(value)) return 'Invalid email';
  return null;
}

String? minLengthValidator(int min) => (String? value) {
  if (value == null || value.length < min) return 'Min $min characters';
  return null;
};

6. Checkbox, Switch, Radio, Slider

// ---- Checkbox ----
bool _isChecked = false;

Checkbox(
  value: _isChecked,
  onChanged: (val) => setState(() => _isChecked = val!),
  activeColor: Colors.blue,
)

// CheckboxListTile — checkbox with a label (common pattern)
CheckboxListTile(
  title: const Text('Remember me'),
  subtitle: const Text('Stay logged in on this device'),
  value: _rememberMe,
  onChanged: (val) => setState(() => _rememberMe = val!),
  secondary: const Icon(Icons.lock),
)

// ---- Switch ----
bool _isDarkMode = false;

Switch(
  value: _isDarkMode,
  onChanged: (val) => setState(() => _isDarkMode = val),
  activeColor: Colors.blue,
)

// SwitchListTile
SwitchListTile(
  title: const Text('Dark Mode'),
  value: _isDarkMode,
  onChanged: (val) => setState(() => _isDarkMode = val),
)

// ---- Radio ----
String _gender = 'male';

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

// ---- Slider ----
double _volume = 0.5;

Slider(
  value: _volume,
  min: 0.0,
  max: 1.0,
  divisions: 10,          // snaps to 10 equal steps
  label: '${(_volume * 100).round()}%',
  onChanged: (val) => setState(() => _volume = val),
)

// RangeSlider — pick a range
RangeValues _priceRange = const RangeValues(20, 80);

RangeSlider(
  values: _priceRange,
  min: 0,
  max: 200,
  divisions: 20,
  labels: RangeLabels('\$${_priceRange.start.round()}', '\$${_priceRange.end.round()}'),
  onChanged: (range) => setState(() => _priceRange = range),
)

7. DropdownButton / DropdownButtonFormField

// ---- DropdownButton (standalone) ----
String? _selectedCountry = 'Myanmar';

DropdownButton<String>(
  value: _selectedCountry,
  hint: const Text('Select country'),
  isExpanded: true,      // fills available width
  underline: const SizedBox(),   // remove default underline
  items: ['Myanmar', 'Thailand', 'Singapore', 'Malaysia']
    .map((country) => DropdownMenuItem(
      value: country,
      child: Text(country),
    ))
    .toList(),
  onChanged: (value) => setState(() => _selectedCountry = value),
)

// ---- DropdownButtonFormField (inside a Form, with validation) ----
DropdownButtonFormField<String>(
  value: _selectedRole,
  decoration: const InputDecoration(
    labelText: 'Role',
    border: OutlineInputBorder(),
    prefixIcon: Icon(Icons.person),
  ),
  items: ['Admin', 'Editor', 'Viewer']
    .map((role) => DropdownMenuItem(value: role, child: Text(role)))
    .toList(),
  validator: (value) => value == null ? 'Please select a role' : null,
  onChanged: (value) => setState(() => _selectedRole = value),
  onSaved: (value) => _role = value!,
)

8. Date & Time Pickers

DateTime? _selectedDate;
TimeOfDay? _selectedTime;

// ---- Date Picker ----
Future<void> _pickDate() async {
  final DateTime? picked = await showDatePicker(
    context: context,
    initialDate: DateTime.now(),
    firstDate: DateTime(2000),
    lastDate: DateTime(2100),
    helpText: 'Select a date',
    cancelText: 'Cancel',
    confirmText: 'Pick',
    builder: (context, child) {
      // Custom theme for the picker dialog
      return Theme(
        data: Theme.of(context).copyWith(
          colorScheme: const ColorScheme.dark(primary: Colors.blue),
        ),
        child: child!,
      );
    },
  );

  if (picked != null) {
    setState(() => _selectedDate = picked);
    print('Date: ${picked.toIso8601String()}');
  }
}

// ---- Time Picker ----
Future<void> _pickTime() async {
  final TimeOfDay? picked = await showTimePicker(
    context: context,
    initialTime: TimeOfDay.now(),
    initialEntryMode: TimePickerEntryMode.dial,
  );

  if (picked != null) {
    setState(() => _selectedTime = picked);
    print('Time: ${picked.format(context)}');
  }
}

// Display widgets
ElevatedButton.icon(
  onPressed: _pickDate,
  icon: const Icon(Icons.calendar_today),
  label: Text(
    _selectedDate == null
      ? 'Pick Date'
      : '${_selectedDate!.day}/${_selectedDate!.month}/${_selectedDate!.year}',
  ),
)

ElevatedButton.icon(
  onPressed: _pickTime,
  icon: const Icon(Icons.access_time),
  label: Text(
    _selectedTime == null ? 'Pick Time' : _selectedTime!.format(context),
  ),
)

9. Complete Login Form Example

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 _passCtrl  = TextEditingController();

  bool _obscurePass = true;
  bool _rememberMe  = false;
  bool _isLoading   = false;

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

  Future<void> _submit() async {
    if (!_formKey.currentState!.validate()) return;
    setState(() => _isLoading = true);
    await Future.delayed(const Duration(seconds: 2));  // simulate API
    setState(() => _isLoading = false);
    if (mounted) Navigator.pushReplacementNamed(context, '/home');
  }

  @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: [
              const Text('Welcome Back', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
              const SizedBox(height: 32),
              TextFormField(
                controller: _emailCtrl,
                decoration: const InputDecoration(
                  labelText: 'Email',
                  prefixIcon: Icon(Icons.email),
                  border: OutlineInputBorder(),
                ),
                keyboardType: TextInputType.emailAddress,
                textInputAction: TextInputAction.next,
                validator: (v) {
                  if (v == null || v.isEmpty) return 'Email required';
                  if (!v.contains('@')) return 'Invalid email';
                  return null;
                },
              ),
              const SizedBox(height: 16),
              TextFormField(
                controller: _passCtrl,
                decoration: InputDecoration(
                  labelText: 'Password',
                  prefixIcon: const Icon(Icons.lock),
                  border: const OutlineInputBorder(),
                  suffixIcon: IconButton(
                    icon: Icon(_obscurePass ? Icons.visibility : Icons.visibility_off),
                    onPressed: () => setState(() => _obscurePass = !_obscurePass),
                  ),
                ),
                obscureText: _obscurePass,
                textInputAction: TextInputAction.done,
                onFieldSubmitted: (_) => _submit(),
                validator: (v) {
                  if (v == null || v.isEmpty) return 'Password required';
                  if (v.length < 6) return 'Min 6 characters';
                  return null;
                },
              ),
              const SizedBox(height: 8),
              CheckboxListTile(
                title: const Text('Remember me'),
                value: _rememberMe,
                onChanged: (v) => setState(() => _rememberMe = v!),
                contentPadding: EdgeInsets.zero,
              ),
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: _isLoading ? null : _submit,
                style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 14)),
                child: _isLoading
                  ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
                  : const Text('Login', style: TextStyle(fontSize: 16)),
              ),
              const SizedBox(height: 12),
              TextButton(onPressed: () {}, child: const Text('Forgot Password?')),
            ],
          ),
        ),
      ),
    );
  }
}

📌 Study Checklist