Dropdown Fields & Radio Groups
Dropdown Fields & Radio Groups
Two of the most common single-selection controls in any form are dropdowns and radio groups. Flutter provides DropdownButtonFormField and Radio/RadioListTile for these use cases. Crucially, both integrate with Flutter's Form validation lifecycle — meaning they participate in Form.validate(), FormState.save(), and AutovalidateMode just like a TextFormField.
DropdownButtonFormField
DropdownButtonFormField<T> is the form-aware wrapper around DropdownButton. It holds a FormField internally, so it can display validation errors inline and participate in the enclosing Form's save/validate cycle. Its key parameters are:
value— the currently selected item (ornullfor no selection)items— aList<DropdownMenuItem<T>>describing each optiononChanged— callback fired when the user picks a new item; set tonullto disablevalidator— returns an error string ornull, called onForm.validate()onSaved— called with the final value onFormState.save()decoration— anInputDecorationidentical toTextFormField
DropdownButtonFormField Example
class CountryForm extends StatefulWidget {
const CountryForm({super.key});
@override
State<CountryForm> createState() => _CountryFormState();
}
class _CountryFormState extends State<CountryForm> {
final _formKey = GlobalKey<FormState>();
String? _selectedCountry;
final List<String> _countries = [
'United States',
'United Kingdom',
'Canada',
'Australia',
'Germany',
];
void _submit() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
debugPrint('Selected country: $_selectedCountry');
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
value: _selectedCountry,
decoration: const InputDecoration(
labelText: 'Country',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.flag),
),
hint: const Text('Select a country'),
items: _countries.map((country) {
return DropdownMenuItem<String>(
value: country,
child: Text(country),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedCountry = value;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please select a country';
}
return null;
},
onSaved: (value) => _selectedCountry = value,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _submit,
child: const Text('Submit'),
),
],
),
);
}
}
value is null and no hint is provided, the dropdown renders an empty box. Always supply a hint widget so users understand the field is interactive before making a selection.Wrapping Radio Buttons in a FormField
Radio<T> and RadioListTile<T> are not FormField subclasses by default. To make a radio group participate in Form.validate(), you must wrap the entire group inside a FormField<T>. The FormField widget takes a builder callback that receives a FormFieldState<T>, letting you call state.didChange(value) from each radio's onChanged and call state.hasError to show an error message below the group.
RadioListTile Group Wrapped in FormField
class ExperienceForm extends StatefulWidget {
const ExperienceForm({super.key});
@override
State<ExperienceForm> createState() => _ExperienceFormState();
}
class _ExperienceFormState extends State<ExperienceForm> {
final _formKey = GlobalKey<FormState>();
String? _experience;
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Years of Experience',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
FormField<String>(
initialValue: _experience,
validator: (value) {
if (value == null) return 'Please select your experience level';
return null;
},
builder: (FormFieldState<String> state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final level in ['0-1 years', '2-4 years', '5-9 years', '10+ years'])
RadioListTile<String>(
title: Text(level),
value: level,
groupValue: state.value,
onChanged: (val) {
state.didChange(val);
setState(() => _experience = val);
},
),
if (state.hasError)
Padding(
padding: const EdgeInsets.only(left: 12, top: 4),
child: Text(
state.errorText!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
),
],
);
},
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
debugPrint('Experience: $_experience');
}
},
child: const Text('Continue'),
),
],
),
);
}
}
RadioListTile instead of bare Radio when you want each option to show a label and have a larger tap target. RadioListTile handles the label, leading/trailing alignment, and the tap area — saving you from wrapping each option in a Row manually.AutovalidateMode with Dropdowns and Radio Groups
By default, validation only runs when you call Form.validate() explicitly. You can change this with the autovalidateMode property on the Form or on individual fields. The most useful modes are:
AutovalidateMode.disabled— only validate on explicit call (default)AutovalidateMode.onUserInteraction— validate as soon as the user touches the field; ideal for dropdowns since the user has made a deliberate selectionAutovalidateMode.always— validate on every rebuild; usually too aggressive
autovalidateMode: AutovalidateMode.always on a FormField that wraps radio buttons. Because each RadioListTile tap calls setState, the entire form rebuilds, which can trigger the validator before the user has had a chance to see the options — resulting in a confusing error displayed the moment the screen loads.Combining Both Controls in One Form
Real-world forms often mix dropdowns and radio groups with text fields. The Form widget holds a single key; calling _formKey.currentState!.validate() validates every FormField descendant, including your custom radio FormField, the DropdownButtonFormField, and any TextFormField — all in one call.
Summary
Use DropdownButtonFormField<T> for compact single-selection lists — it is a drop-in FormField replacement with built-in error display and InputDecoration support. For radio groups, wrap your Radio/RadioListTile widgets inside a FormField<T>, call state.didChange() from each onChanged, and render state.errorText below the group. Both patterns let you validate and save the user's choice as part of the standard Flutter form lifecycle.