İçeriğe geç / Skip to content / Zum Inhalt

Flutter: Input Widgets and Status Indicators

Ahmet Balaman

Last updated:

7 min read

FlutterSwitchCheckboxRadioSliderProgressIndicator
Flutter: Input Widgets and Status Indicators

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.

Comments