Flutter: Input Widgets and Status Indicators
Last updated:
7 min read

Getting input from the user doesn't always mean opening the keyboard. We use a Switch for an on/off setting, a Checkbox for multiple choice, a Radio for a single choice and a Slider for a value from a range; while work is in progress, a progress indicator keeps the user informed. This post covers all of them, which one is the right pick in which situation, and the traps people fall into most often.
Live Demo
You can try these widgets in the interactive example below:
💡 If the example above doesn't load, click DartPad to run it in a new tab.
Selection Widgets
All of these widgets follow the same logic: they don't hold the value themselves. We pass the current value in value, the user touches the widget, onChanged reports the new value, and we update our variable with setState so the screen is redrawn.
1. Switch
Used to turn a setting on or off. The change takes effect immediately; it doesn't wait for a "save" button.
bool isSwitched = false;
Switch(
value: isSwitched,
onChanged: (value) {
setState(() {
isSwitched = value;
});
},
)On real screens it almost always appears together with its label, as a SwitchListTile. That makes the whole row tappable:
SwitchListTile(
title: const Text('Enable Notifications'),
subtitle: const Text('Receive app notifications'),
value: isSwitched,
onChanged: (value) => setState(() => isSwitched = value),
)2. Checkbox
Used to tick several independent options or to confirm something.
bool isChecked = false;
Checkbox(
value: isChecked,
onChanged: (value) {
setState(() {
isChecked = value ?? false;
});
},
)The onChanged parameter has the type bool? because with tristate: true a Checkbox can also show a third, "indeterminate" state (null). That exists for cases like "some of the child items are selected". For a labeled version, CheckboxListTile is more practical.
3. Radio
Used to pick exactly one option from a group. In current Flutter versions the group's selected value is managed by a RadioGroup:
int selectedValue = 1;
RadioGroup<int>(
groupValue: selectedValue,
onChanged: (value) => setState(() => selectedValue = value ?? 1),
child: const Column(
children: [
RadioListTile<int>(title: Text('Option A'), value: 1),
RadioListTile<int>(title: Text('Option B'), value: 2),
],
),
)In older examples you will see groupValue and onChanged passed to every RadioListTile separately. That still compiles but triggers a deprecation warning in newer versions; prefer RadioGroup in new code.
4. Slider
Used to pick a numeric value from a range: volume, font size, a maximum price.
double currentSliderValue = 20;
Slider(
value: currentSliderValue,
min: 0,
max: 100,
divisions: 5,
label: currentSliderValue.round().toString(),
onChanged: (double value) {
setState(() {
currentSliderValue = value;
});
},
)Without divisions the slider produces continuous values. With the default theme, the label bubble only appears when divisions is set; that is usually the answer to "I set a label but it never shows". To pick a lower and an upper bound together there is RangeSlider, which works the same way.
5. ToggleButtons and SegmentedButton
ToggleButtons is a group of buttons lined up side by side. We track whether each button is selected in a List<bool>, which makes it a good fit for independent choices such as bold/italic/underline:
List<bool> isSelected = [false, true, false];
ToggleButtons(
isSelected: isSelected,
onPressed: (int index) {
setState(() {
isSelected[index] = !isSelected[index];
});
},
children: const [
Icon(Icons.format_bold),
Icon(Icons.format_italic),
Icon(Icons.format_underlined),
],
)In Material 3 the same job is done by SegmentedButton. It keeps the selection as a Set of actual values instead of a list of booleans, which reads far better, especially with enums. We will use it in the settings screen scenario below.
Progress Indicators
They keep the screen from looking frozen while something is happening. There are two modes: with no value the indicator spins forever (indeterminate); with a value between 0.0 and 1.0 it is filled by that fraction (determinate).
1. CircularProgressIndicator
Used while a page or list is loading and the duration is unknown.
// Indeterminate (spins continuously)
const CircularProgressIndicator()
// Determinate (50% full)
const CircularProgressIndicator(value: 0.5)Its typical home is a screen that waits for data; inside a FutureBuilder this indicator spins in the center while the connection is pending.
2. LinearProgressIndicator
Used where progress can be measured, such as a file download or a multi-step process, usually at the top of the screen or along the bottom of a card.
LinearProgressIndicator(value: downloaded / total)If you can compute a percentage, prefer the determinate mode. "How much is left" is far more reassuring to a user than a spinning ring.
A Short Note on WebView
Since it often comes up under this topic: to show a web page inside your app you use the webview_flutter package, developed by the Flutter team. Add it with flutter pub add webview_flutter, create a WebViewController and put it on screen with WebViewWidget(controller: controller). It suits content that already lives on the web, such as a privacy policy; building the app's main screens with a WebView defeats the purpose of using Flutter.
When to Use It and When Not To
| Need | Right widget | Wrong choice |
|---|---|---|
| On/off setting that applies instantly | Switch |
Checkbox (suggests confirm/submit) |
| Independent multiple choice, giving consent | Checkbox |
Switch |
| One of 2–5 options, all visible | Radio or SegmentedButton |
Dropdown (hides the options) |
| One of many options | DropdownButton | A long Radio list |
| Approximate numeric value | Slider |
TextField |
| Exact numeric value (age, quantity) | TextField with numeric keyboard |
Slider (hitting an exact value is hard) |
One more distinction: if these widgets save the moment they change, they are enough on their own. If the choices are validated together with a "Submit" button, like the "I accept the terms" box on a sign-up screen, they need to join the Form widget through a FormField.
Common Mistakes
1. Forgetting setState
Symptom: you tap the Switch, onChanged runs, but nothing changes visually. Because these widgets don't hold their own value, the screen keeps the old value unless the variable is updated inside setState.
// Wrong: the variable changes but the screen isn't redrawn
onChanged: (value) => isSwitched = value,
// Right
onChanged: (value) => setState(() => isSwitched = value),2. Passing null to onChanged without noticing
Symptom: the widget looks gray and ignores touches. In Flutter onChanged: null means "disabled". Used deliberately this is very handy (locking settings while a save is running, for example), but the same result appears by accident when your own widget has an optional callback parameter and you forget to pass it.
3. A Slider value outside its range
Symptom: an assertion error containing value >= min && value <= max. It usually happens when the value is read from a saved setting or when max shrinks dynamically. Clamp the value before handing it to the Slider:
Slider(
value: savedValue.clamp(0, 100).toDouble(),
min: 0,
max: 100,
onChanged: (v) => setState(() => savedValue = v),
)4. Saving on every Slider step
onChanged fires over and over while the finger moves. If it writes to disk or makes a network call, dragging stutters and dozens of pointless requests go out. Keep only setState in onChanged and do the persistent work in onChangeEnd, which runs when the finger is lifted.
5. Dropping a bare LinearProgressIndicator into a Row
Symptom: the "BoxConstraints forces an infinite width" error. A linear indicator wants all the width it can get, while a Row offers its children unbounded width. Wrap the indicator in Expanded or give it a width with SizedBox.
Mini Scenario: A Settings Screen
Let's see them together: a notification switch, a theme selector, a font size slider and a thin progress bar that shows up at the top while saving.
enum AppTheme { system, light, dark }
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
bool _notifications = true;
AppTheme _theme = AppTheme.system;
double _fontScale = 1.0;
bool _isSaving = false;
Future<void> _save() async {
setState(() => _isSaving = true);
await Future.delayed(const Duration(milliseconds: 600)); // Stands in for persistence
if (!mounted) return;
setState(() => _isSaving = false);
}
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Keeps its space so the content doesn't jump
Opacity(
opacity: _isSaving ? 1 : 0,
child: const LinearProgressIndicator(),
),
SwitchListTile(
title: const Text('Notifications'),
value: _notifications,
onChanged: (value) {
setState(() => _notifications = value);
_save();
},
),
const SizedBox(height: 8),
SegmentedButton<AppTheme>(
segments: const [
ButtonSegment(value: AppTheme.system, label: Text('System')),
ButtonSegment(value: AppTheme.light, label: Text('Light')),
ButtonSegment(value: AppTheme.dark, label: Text('Dark')),
],
selected: {_theme},
onSelectionChanged: (selection) {
setState(() => _theme = selection.first);
_save();
},
),
const SizedBox(height: 16),
Text('Font size: ${(_fontScale * 100).round()}%'),
Slider(
value: _fontScale,
min: 0.8,
max: 1.4,
divisions: 6,
label: '${(_fontScale * 100).round()}%',
onChanged: (value) => setState(() => _fontScale = value),
onChangeEnd: (_) => _save(),
),
],
);
}
}The Switch and the theme selector save the moment they change; the Slider only updates the screen while being dragged and saves once the finger is lifted. Instead of adding and removing the progress bar with an if, we hide it with Opacity, so the rows below don't shift up and down as it appears and disappears. Since these settings affect more than one screen, in a real project the values belong not in this page's State but in a state management solution such as Provider. What replaces the Future.delayed line in _save, the actual persistence that keeps the values after the app closes, is covered in local data storage with SharedPreferences.
Frequently Asked Questions
What is the difference between Switch and Checkbox?
A Switch turns a setting on or off with immediate effect, like a light switch. A Checkbox is for independent selections from a list or for giving consent before a submission, and its effect usually happens on "Save" or "Submit".
Should I use Radio or DropdownButton?
With few options (up to about five) where seeing them all at a glance helps, Radio or SegmentedButton is better. With many options or little screen space, choose DropdownButton.
Why doesn't the Slider label show up?
With the default theme the value bubble is only shown on sliders that have the divisions parameter, in other words discrete sliders. Adding divisions or changing showValueIndicator through a SliderTheme fixes it.
CircularProgressIndicator or LinearProgressIndicator?
If progress can't be measured and the whole screen is waiting, a circular indicator in the center fits. If progress is known as a percentage, or the rest of the screen remains usable, a thin linear indicator at the top is less intrusive.
Related Posts
Flutter: Light and Dark Themes with ThemeData
Building a Flutter theme with ThemeData: ColorScheme.fromSeed, light and dark themes, ThemeMode, reading with Theme.of, TextTheme, component themes, extensions.
Flutter: Using TextField and TextEditingController
Flutter TextField guide: TextEditingController and dispose, InputDecoration, keyboard types, inputFormatters, password fields, FocusNode and onChanged.
Flutter: Layering Widgets with Stack and Positioned
Flutter Stack and Positioned guide: how a Stack sizes itself, fit, alignment, clipBehavior, Positioned.fill, PositionedDirectional, badge and overlay recipes.