Checkboxes, Switches & Multi-Select Patterns
Checkboxes, Switches & Multi-Select Patterns
Flutter provides Checkbox and Switch widgets for capturing boolean input, but by default they are not connected to the Form / FormField validation pipeline. To make them first-class form citizens — so that calling _formKey.currentState!.validate() includes their state — you must wrap them inside a custom FormField<bool>. This lesson covers how to do exactly that, and then extends the pattern to a multi-select checklist backed by a FormField<List<String>>.
Why Plain Checkbox and Switch Are Not Enough
A bare Checkbox widget stores no state itself; you must manage it with setState. That is fine for simple UI toggles, but it means the value sits outside the form's validation scope. If a user must accept terms before submitting, you need validation to enforce that rule uniformly with all other fields. Wrapping in FormField achieves this cleanly.
FormField<T> is the base class behind TextFormField. It accepts an initialValue, a validator callback, an onSaved callback, and a builder that receives a FormFieldState<T> — giving you full access to the current value, error text, and save/validate hooks.Building a CheckboxFormField
The key idea is to pass a builder to FormField<bool>. The builder receives FormFieldState<bool> field, which you can call field.didChange(newValue) on to update the form's internal state and trigger validation.
Custom CheckboxFormField Example
class CheckboxFormField extends FormField<bool> {
CheckboxFormField({
super.key,
required String label,
super.initialValue = false,
super.validator,
super.onSaved,
super.autovalidateMode,
}) : super(
builder: (FormFieldState<bool> field) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Checkbox(
value: field.value ?? false,
onChanged: (bool? checked) {
field.didChange(checked);
},
),
Flexible(child: Text(label)),
],
),
if (field.hasError)
Padding(
padding: const EdgeInsets.only(left: 12),
child: Text(
field.errorText!,
style: const TextStyle(
color: Colors.red,
fontSize: 12,
),
),
),
],
);
},
);
}
// Usage inside a Form:
CheckboxFormField(
label: 'I agree to the Terms and Conditions',
validator: (value) {
if (value != true) return 'You must accept the terms to continue.';
return null;
},
onSaved: (value) => _agreedToTerms = value ?? false,
)
field.didChange(newValue) inside the widget's callback — never call setState() directly. didChange updates the FormFieldState's internal value, marks the field dirty, and (when AutovalidateMode is active) triggers immediate revalidation.Building a SwitchFormField
The pattern for Switch is identical — only the inner widget changes. You can even make a reusable generic boolean FormField that accepts a builder parameter to swap between Checkbox and Switch rendering.
Inline SwitchFormField Inside a Form
FormField<bool>(
initialValue: false,
validator: (value) {
if (value != true) return 'Please enable notifications to proceed.';
return null;
},
onSaved: (value) => _notificationsEnabled = value ?? false,
builder: (FormFieldState<bool> field) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
title: const Text('Enable push notifications'),
subtitle: const Text('Required for order updates'),
value: field.value ?? false,
onChanged: field.didChange,
),
if (field.hasError)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
field.errorText!,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
),
],
);
},
)
Multi-Select Checklist Pattern
A multi-select checklist lets the user pick several options from a fixed set. Instead of FormField<bool>, you use FormField<List<String>>. The builder renders a column of CheckboxListTile widgets, and each tap calls field.didChange(updatedList) with a new copy of the list.
MultiSelectFormField for a List of Strings
class MultiSelectFormField extends FormField<List<String>> {
MultiSelectFormField({
super.key,
required List<String> options,
String? label,
super.initialValue,
super.validator,
super.onSaved,
}) : super(
builder: (FormFieldState<List<String>> field) {
final selected = field.value ?? <String>[];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (label != null)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
...options.map((option) {
return CheckboxListTile(
title: Text(option),
value: selected.contains(option),
onChanged: (bool? checked) {
final next = List<String>.from(selected);
if (checked == true) {
next.add(option);
} else {
next.remove(option);
}
field.didChange(next);
},
controlAffinity: ListTileControlAffinity.leading,
dense: true,
);
}),
if (field.hasError)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
field.errorText!,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
),
],
);
},
);
}
// Usage:
MultiSelectFormField(
label: 'Select your interests (at least 2)',
options: const ['Flutter', 'Dart', 'Firebase', 'REST APIs', 'GraphQL'],
initialValue: const [],
validator: (value) {
if (value == null || value.length < 2) {
return 'Please select at least 2 interests.';
}
return null;
},
onSaved: (value) => _selectedInterests = value ?? [],
)
field.didChange() — never mutate the existing list in place. The FormFieldState stores a reference; mutating it directly bypasses the dirty-checking mechanism and the field will not revalidate or rebuild correctly. Use List<String>.from(selected) or the spread operator to create a fresh copy.Integrating with Form Validation and Save
Because all three patterns (CheckboxFormField, SwitchFormField, MultiSelectFormField) extend FormField, they integrate seamlessly with the standard Form workflow:
_formKey.currentState!.validate()— calls every field'svalidator, including boolean and list fields_formKey.currentState!.save()— calls every field'sonSaved, populating your state variables_formKey.currentState!.reset()— resets all fields to theirinitialValue
Summary
Wrapping Checkbox and Switch in a FormField<bool> gives them full participation in form validation and saving, removing the need for parallel setState management. Extending this to FormField<List<String>> covers the multi-select checklist case. The critical rule is to always call field.didChange() with a new value/copy rather than mutating state directly, so that Flutter's form machinery tracks changes correctly.