Flutter Widgets Fundamentals

Input Widgets: TextField & Checkbox

50 min Lesson 9 of 18

The TextField Widget

TextField is Flutter’s primary widget for text input. It allows users to type text, numbers, passwords, and other data. TextField is highly customizable through its InputDecoration property, which controls the visual appearance of the input field including labels, hints, icons, borders, and error states.

Basic TextField

// Simple text field
const TextField(
  decoration: InputDecoration(
    labelText: 'Username',
    hintText: 'Enter your username',
  ),
)

// Text field with border
const TextField(
  decoration: InputDecoration(
    labelText: 'Email',
    hintText: 'you@example.com',
    border: OutlineInputBorder(),
  ),
)

TextEditingController

To read, set, or listen to changes in a TextField’s value, you use a TextEditingController. This is one of the most important concepts when working with text input in Flutter.

Using TextEditingController

class MyForm extends StatefulWidget {
  const MyForm({super.key});

  @override
  State<MyForm> createState() => _MyFormState();
}

class _MyFormState extends State<MyForm> {
  // Create the controller
  final _nameController = TextEditingController();

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

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          controller: _nameController,
          decoration: const InputDecoration(
            labelText: 'Full Name',
            border: OutlineInputBorder(),
          ),
        ),
        const SizedBox(height: 16),
        ElevatedButton(
          onPressed: () {
            // Read the current value
            final name = _nameController.text;
            debugPrint('Name: \$name');

            // Set a value programmatically
            // _nameController.text = 'New Value';

            // Clear the field
            // _nameController.clear();
          },
          child: const Text('Submit'),
        ),
      ],
    );
  }
}
Warning: Always dispose of your TextEditingController in the dispose() method of your State class. Failing to do so causes memory leaks, as the controller continues to listen for changes even after the widget is removed from the tree.

InputDecoration Deep Dive

InputDecoration provides extensive customization for the TextField’s appearance:

InputDecoration Properties

TextField(
  decoration: InputDecoration(
    // Labels and hints
    labelText: 'Email Address',
    hintText: 'you@example.com',
    helperText: 'We will never share your email',

    // Prefix and suffix
    prefixIcon: const Icon(Icons.email),
    suffixIcon: IconButton(
      icon: const Icon(Icons.clear),
      onPressed: () {
        // Clear field
      },
    ),

    // Prefix/suffix text (inline)
    // prefixText: '\$',
    // suffixText: 'USD',

    // Border styles
    border: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
    ),
    focusedBorder: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
      borderSide: const BorderSide(
        color: Colors.blue,
        width: 2,
      ),
    ),
    enabledBorder: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
      borderSide: BorderSide(
        color: Colors.grey[300]!,
      ),
    ),

    // Fill
    filled: true,
    fillColor: Colors.grey[50],

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

Error State

Display validation errors using errorText:

TextField with Error Handling

class EmailField extends StatefulWidget {
  const EmailField({super.key});

  @override
  State<EmailField> createState() => _EmailFieldState();
}

class _EmailFieldState extends State<EmailField> {
  final _controller = TextEditingController();
  String? _errorText;

  void _validate() {
    setState(() {
      final text = _controller.text;
      if (text.isEmpty) {
        _errorText = 'Email is required';
      } else if (!text.contains('@')) {
        _errorText = 'Please enter a valid email';
      } else {
        _errorText = null;
      }
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: _controller,
      decoration: InputDecoration(
        labelText: 'Email',
        prefixIcon: const Icon(Icons.email),
        border: const OutlineInputBorder(),
        errorText: _errorText,
        errorBorder: const OutlineInputBorder(
          borderSide: BorderSide(color: Colors.red),
        ),
      ),
      onChanged: (value) => _validate(),
      keyboardType: TextInputType.emailAddress,
    );
  }
}

TextField Callbacks

TextField provides several callback options for responding to user input:

TextField Callbacks

TextField(
  // Called every time the text changes
  onChanged: (String value) {
    debugPrint('Current value: \$value');
  },

  // Called when the user presses the action button (Enter/Done)
  onSubmitted: (String value) {
    debugPrint('Submitted: \$value');
  },

  // Called when the field gains/loses focus
  onTap: () {
    debugPrint('Field tapped');
  },

  // Called when editing is complete (field loses focus)
  onEditingComplete: () {
    debugPrint('Editing complete');
  },

  decoration: const InputDecoration(
    labelText: 'Search',
    border: OutlineInputBorder(),
  ),

  // Keyboard action button type
  textInputAction: TextInputAction.search,
)

Password Field with Visibility Toggle

The obscureText property hides the input characters, commonly used for password fields:

Password Field

class PasswordField extends StatefulWidget {
  const PasswordField({super.key});

  @override
  State<PasswordField> createState() => _PasswordFieldState();
}

class _PasswordFieldState extends State<PasswordField> {
  final _controller = TextEditingController();
  bool _obscure = true;

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: _controller,
      obscureText: _obscure,
      decoration: InputDecoration(
        labelText: 'Password',
        prefixIcon: const Icon(Icons.lock),
        suffixIcon: IconButton(
          icon: Icon(
            _obscure ? Icons.visibility_off : Icons.visibility,
          ),
          onPressed: () {
            setState(() {
              _obscure = !_obscure;
            });
          },
        ),
        border: const OutlineInputBorder(),
      ),
    );
  }
}
Tip: Always provide a visibility toggle for password fields. Users appreciate being able to verify what they typed, especially on mobile where typing is more error-prone.

TextField Keyboard Types

Set the keyboardType to show the appropriate keyboard layout:

Keyboard Types

// Email keyboard (with @ and .com)
TextField(
  keyboardType: TextInputType.emailAddress,
  decoration: const InputDecoration(labelText: 'Email'),
)

// Number keyboard
TextField(
  keyboardType: TextInputType.number,
  decoration: const InputDecoration(labelText: 'Age'),
)

// Phone keyboard
TextField(
  keyboardType: TextInputType.phone,
  decoration: const InputDecoration(labelText: 'Phone'),
)

// Multiline text
TextField(
  keyboardType: TextInputType.multiline,
  maxLines: 4,
  decoration: const InputDecoration(
    labelText: 'Bio',
    alignLabelWithHint: true,
    border: OutlineInputBorder(),
  ),
)

// URL keyboard
TextField(
  keyboardType: TextInputType.url,
  decoration: const InputDecoration(labelText: 'Website'),
)

The Checkbox Widget

The Checkbox widget is a Material Design checkbox that can be either checked or unchecked. It requires a value and an onChanged callback.

Basic Checkbox

class CheckboxDemo extends StatefulWidget {
  const CheckboxDemo({super.key});

  @override
  State<CheckboxDemo> createState() => _CheckboxDemoState();
}

class _CheckboxDemoState extends State<CheckboxDemo> {
  bool _isChecked = false;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Checkbox(
          value: _isChecked,
          onChanged: (bool? value) {
            setState(() {
              _isChecked = value ?? false;
            });
          },
        ),
        const Text('I agree to the terms and conditions'),
      ],
    );
  }
}

Key Checkbox properties:

  • value -- Whether the checkbox is checked (true), unchecked (false), or in a tristate (null).
  • onChanged -- Callback when the checkbox is tapped. Set to null to disable the checkbox.
  • activeColor -- Color of the checkbox when checked.
  • checkColor -- Color of the check icon inside the checkbox.
  • tristate -- If true, the checkbox can also have a null (indeterminate) state.
  • shape -- The shape of the checkbox (e.g., rounded).

CheckboxListTile

CheckboxListTile combines a Checkbox with a ListTile for a cleaner, more tappable layout:

CheckboxListTile Examples

class TaskList extends StatefulWidget {
  const TaskList({super.key});

  @override
  State<TaskList> createState() => _TaskListState();
}

class _TaskListState extends State<TaskList> {
  final Map<String, bool> _tasks = {
    'Buy groceries': false,
    'Clean the house': true,
    'Walk the dog': false,
    'Read a book': true,
  };

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.all(16),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: _tasks.entries.map((entry) {
          return CheckboxListTile(
            title: Text(
              entry.key,
              style: TextStyle(
                decoration: entry.value
                    ? TextDecoration.lineThrough
                    : null,
                color: entry.value ? Colors.grey : null,
              ),
            ),
            value: entry.value,
            onChanged: (bool? value) {
              setState(() {
                _tasks[entry.key] = value ?? false;
              });
            },
            controlAffinity:
                ListTileControlAffinity.leading,
            activeColor: Colors.green,
          );
        }).toList(),
      ),
    );
  }
}
Note: controlAffinity determines where the checkbox appears: ListTileControlAffinity.leading places it at the start (common for task lists), while ListTileControlAffinity.trailing places it at the end (common for settings). The default is platform which varies by OS.

Tristate Checkbox

A tristate checkbox has three states: checked (true), unchecked (false), and indeterminate (null). This is useful for "select all" scenarios:

Tristate Checkbox Example

class SelectAllDemo extends StatefulWidget {
  const SelectAllDemo({super.key});

  @override
  State<SelectAllDemo> createState() => _SelectAllDemoState();
}

class _SelectAllDemoState extends State<SelectAllDemo> {
  final List<bool> _items = [false, true, false];

  bool? get _selectAll {
    if (_items.every((item) => item)) return true;
    if (_items.every((item) => !item)) return false;
    return null; // indeterminate
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        CheckboxListTile(
          title: const Text(
            'Select All',
            style: TextStyle(fontWeight: FontWeight.bold),
          ),
          tristate: true,
          value: _selectAll,
          onChanged: (bool? value) {
            setState(() {
              final newValue = value ?? false;
              for (int i = 0; i < _items.length; i++) {
                _items[i] = newValue;
              }
            });
          },
        ),
        const Divider(),
        ...List.generate(_items.length, (index) {
          return CheckboxListTile(
            title: Text('Item \${index + 1}'),
            value: _items[index],
            onChanged: (bool? value) {
              setState(() {
                _items[index] = value ?? false;
              });
            },
            controlAffinity: ListTileControlAffinity.leading,
          );
        }),
      ],
    );
  }
}

Practical Example: Login Form

Complete Login Form

class LoginScreen extends StatefulWidget {
  const LoginScreen({super.key});

  @override
  State<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _obscurePassword = true;
  bool _rememberMe = false;

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              const Text(
                'Welcome Back',
                style: TextStyle(
                  fontSize: 28,
                  fontWeight: FontWeight.bold,
                ),
                textAlign: TextAlign.center,
              ),
              const SizedBox(height: 32),
              TextField(
                controller: _emailController,
                keyboardType: TextInputType.emailAddress,
                textInputAction: TextInputAction.next,
                decoration: InputDecoration(
                  labelText: 'Email',
                  prefixIcon: const Icon(Icons.email_outlined),
                  border: OutlineInputBorder(
                    borderRadius: BorderRadius.circular(12),
                  ),
                ),
              ),
              const SizedBox(height: 16),
              TextField(
                controller: _passwordController,
                obscureText: _obscurePassword,
                textInputAction: TextInputAction.done,
                decoration: InputDecoration(
                  labelText: 'Password',
                  prefixIcon: const Icon(Icons.lock_outlined),
                  suffixIcon: IconButton(
                    icon: Icon(
                      _obscurePassword
                          ? Icons.visibility_off
                          : Icons.visibility,
                    ),
                    onPressed: () {
                      setState(() {
                        _obscurePassword = !_obscurePassword;
                      });
                    },
                  ),
                  border: OutlineInputBorder(
                    borderRadius: BorderRadius.circular(12),
                  ),
                ),
              ),
              const SizedBox(height: 8),
              CheckboxListTile(
                title: const Text('Remember me'),
                value: _rememberMe,
                onChanged: (value) {
                  setState(() {
                    _rememberMe = value ?? false;
                  });
                },
                controlAffinity: ListTileControlAffinity.leading,
                contentPadding: EdgeInsets.zero,
              ),
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: () {
                  debugPrint('Email: \${_emailController.text}');
                  debugPrint('Remember: \$_rememberMe');
                },
                style: ElevatedButton.styleFrom(
                  padding: const EdgeInsets.symmetric(vertical: 16),
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(12),
                  ),
                ),
                child: const Text('Log In', style: TextStyle(fontSize: 16)),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Practical Example: Todo Checklist

Todo Checklist with TextField

class TodoChecklist extends StatefulWidget {
  const TodoChecklist({super.key});

  @override
  State<TodoChecklist> createState() => _TodoChecklistState();
}

class _TodoChecklistState extends State<TodoChecklist> {
  final _controller = TextEditingController();
  final List<Map<String, dynamic>> _todos = [];

  void _addTodo() {
    if (_controller.text.isNotEmpty) {
      setState(() {
        _todos.add({
          'title': _controller.text,
          'done': false,
        });
        _controller.clear();
      });
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Todo List')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: TextField(
              controller: _controller,
              onSubmitted: (_) => _addTodo(),
              decoration: InputDecoration(
                hintText: 'Add a new task...',
                suffixIcon: IconButton(
                  icon: const Icon(Icons.add_circle),
                  onPressed: _addTodo,
                ),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(12),
                ),
              ),
            ),
          ),
          Expanded(
            child: ListView.builder(
              itemCount: _todos.length,
              itemBuilder: (context, index) {
                final todo = _todos[index];
                return CheckboxListTile(
                  title: Text(
                    todo['title'] as String,
                    style: TextStyle(
                      decoration: (todo['done'] as bool)
                          ? TextDecoration.lineThrough
                          : null,
                    ),
                  ),
                  value: todo['done'] as bool,
                  onChanged: (value) {
                    setState(() {
                      _todos[index]['done'] = value;
                    });
                  },
                  controlAffinity:
                      ListTileControlAffinity.leading,
                  secondary: IconButton(
                    icon: const Icon(Icons.delete_outline),
                    onPressed: () {
                      setState(() {
                        _todos.removeAt(index);
                      });
                    },
                  ),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

Practice Exercise

Build a registration form screen with the following: (1) Four TextFields: full name, email, phone number, and password -- each with appropriate keyboardType, InputDecoration with prefix icons, and validation error text. (2) A password visibility toggle using obscureText and a suffix icon. (3) A CheckboxListTile for "I agree to the terms". (4) A submit button that is disabled when the checkbox is unchecked. (5) Display validation errors using errorText when the user submits with empty fields. Challenge: Add a password strength indicator that updates in real-time as the user types, using onChanged.