Flutter Widgets Fundamentals

Switch, Slider & Radio

45 min Lesson 10 of 18

The Switch Widget

The Switch widget is a Material Design toggle that allows users to turn a setting on or off. It is commonly used for boolean preferences like enabling dark mode, notifications, or Wi-Fi. A Switch has two states: on (true) and off (false).

Basic Switch

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

  @override
  State<SwitchDemo> createState() => _SwitchDemoState();
}

class _SwitchDemoState extends State<SwitchDemo> {
  bool _isEnabled = false;

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        const Text('Enable Feature'),
        Switch(
          value: _isEnabled,
          onChanged: (bool value) {
            setState(() {
              _isEnabled = value;
            });
          },
        ),
      ],
    );
  }
}

Key Switch properties:

  • value -- Whether the switch is on (true) or off (false).
  • onChanged -- Callback when the user toggles the switch. Set to null to disable.
  • activeColor -- Color of the thumb when the switch is on.
  • activeTrackColor -- Color of the track when the switch is on.
  • inactiveThumbColor -- Color of the thumb when off.
  • inactiveTrackColor -- Color of the track when off.
  • thumbIcon -- An icon displayed on the switch thumb (Material 3).

SwitchListTile

SwitchListTile combines a Switch with a ListTile, providing a full-width tappable area. This is the preferred way to use switches in settings screens.

SwitchListTile Examples

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

  @override
  State<NotificationSettings> createState() =>
      _NotificationSettingsState();
}

class _NotificationSettingsState
    extends State<NotificationSettings> {
  bool _pushNotifications = true;
  bool _emailNotifications = false;
  bool _soundEnabled = true;

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.all(16),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          SwitchListTile(
            title: const Text('Push Notifications'),
            subtitle: const Text('Receive push alerts'),
            secondary: const Icon(Icons.notifications),
            value: _pushNotifications,
            onChanged: (value) {
              setState(() => _pushNotifications = value);
            },
          ),
          const Divider(height: 1),
          SwitchListTile(
            title: const Text('Email Notifications'),
            subtitle: const Text('Get updates via email'),
            secondary: const Icon(Icons.email),
            value: _emailNotifications,
            onChanged: (value) {
              setState(() => _emailNotifications = value);
            },
          ),
          const Divider(height: 1),
          SwitchListTile(
            title: const Text('Sound'),
            subtitle: const Text('Play notification sounds'),
            secondary: const Icon(Icons.volume_up),
            value: _soundEnabled,
            onChanged: _pushNotifications
                ? (value) {
                    setState(() => _soundEnabled = value);
                  }
                : null, // Disabled when push is off
          ),
        ],
      ),
    );
  }
}
Tip: Disable dependent settings when their parent setting is off. In the example above, the Sound toggle is disabled when Push Notifications is turned off. This provides a clear visual hint about the dependency between settings.

Custom Styled Switch

You can customize the Switch appearance using its color and icon properties:

Styled Switch with Thumb Icon

Switch(
  value: _isEnabled,
  onChanged: (value) {
    setState(() => _isEnabled = value);
  },
  activeColor: Colors.green,
  activeTrackColor: Colors.green[200],
  inactiveThumbColor: Colors.grey,
  inactiveTrackColor: Colors.grey[300],
  thumbIcon: WidgetStateProperty.resolveWith<Icon?>(
    (Set<WidgetState> states) {
      if (states.contains(WidgetState.selected)) {
        return const Icon(Icons.check, color: Colors.white);
      }
      return const Icon(Icons.close, color: Colors.white);
    },
  ),
)

CupertinoSwitch

For an iOS-style toggle, use CupertinoSwitch from the Cupertino library:

CupertinoSwitch

import 'package:flutter/cupertino.dart';

CupertinoSwitch(
  value: _isEnabled,
  activeColor: CupertinoColors.activeGreen,
  onChanged: (bool value) {
    setState(() {
      _isEnabled = value;
    });
  },
)

The Slider Widget

The Slider widget lets users select a value from a continuous or discrete range by dragging a thumb along a track. Common uses include volume controls, brightness settings, and filter ranges.

Basic Slider

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

  @override
  State<SliderDemo> createState() => _SliderDemoState();
}

class _SliderDemoState extends State<SliderDemo> {
  double _volume = 50;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text(
          'Volume: \${_volume.round()}',
          style: const TextStyle(fontSize: 18),
        ),
        Slider(
          value: _volume,
          min: 0,
          max: 100,
          onChanged: (double value) {
            setState(() {
              _volume = value;
            });
          },
        ),
      ],
    );
  }
}

Key Slider properties:

  • value -- The current value of the slider.
  • min / max -- The minimum and maximum values (defaults: 0.0 and 1.0).
  • divisions -- Number of discrete divisions. If null, the slider is continuous.
  • label -- A label displayed above the thumb when using divisions.
  • onChanged -- Called continuously as the user drags the slider.
  • onChangeStart -- Called when the user starts dragging.
  • onChangeEnd -- Called when the user stops dragging.
  • activeColor -- Color of the active portion of the track (before the thumb).
  • inactiveColor -- Color of the inactive portion (after the thumb).

Discrete Slider with Divisions

Adding divisions makes the slider snap to discrete values and shows a value indicator label:

Discrete Slider

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

  @override
  State<FontSizeSlider> createState() => _FontSizeSliderState();
}

class _FontSizeSliderState extends State<FontSizeSlider> {
  double _fontSize = 16;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text(
          'Preview Text',
          style: TextStyle(fontSize: _fontSize),
        ),
        const SizedBox(height: 16),
        Slider(
          value: _fontSize,
          min: 10,
          max: 36,
          divisions: 26,
          label: '\${_fontSize.round()} pt',
          onChanged: (double value) {
            setState(() {
              _fontSize = value;
            });
          },
        ),
        Text('Font Size: \${_fontSize.round()} pt'),
      ],
    );
  }
}
Note: The label property only appears when divisions is set. It shows a floating indicator above the thumb displaying the current value, which is very helpful for discrete selection.

RangeSlider

The RangeSlider allows users to select a range of values by providing two thumbs:

RangeSlider Example

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

  @override
  State<PriceRangeFilter> createState() =>
      _PriceRangeFilterState();
}

class _PriceRangeFilterState extends State<PriceRangeFilter> {
  RangeValues _priceRange = const RangeValues(20, 80);

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          'Price: \$\${_priceRange.start.round()} - '
          '\$\${_priceRange.end.round()}',
          style: const TextStyle(
            fontSize: 18,
            fontWeight: FontWeight.bold,
          ),
        ),
        RangeSlider(
          values: _priceRange,
          min: 0,
          max: 100,
          divisions: 20,
          labels: RangeLabels(
            '\$\${_priceRange.start.round()}',
            '\$\${_priceRange.end.round()}',
          ),
          onChanged: (RangeValues values) {
            setState(() {
              _priceRange = values;
            });
          },
        ),
      ],
    );
  }
}

CupertinoSlider

For an iOS-style slider, use CupertinoSlider:

CupertinoSlider

import 'package:flutter/cupertino.dart';

CupertinoSlider(
  value: _volume,
  min: 0,
  max: 100,
  divisions: 100,
  onChanged: (double value) {
    setState(() {
      _volume = value;
    });
  },
)

The Radio Widget

The Radio widget allows users to select one option from a set of mutually exclusive choices. All Radio buttons in a group share the same groupValue, and only one can be selected at a time.

Basic Radio Group

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

  @override
  State<GenderSelection> createState() => _GenderSelectionState();
}

class _GenderSelectionState extends State<GenderSelection> {
  String _selectedGender = 'male';

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Text(
          'Gender',
          style: TextStyle(
            fontSize: 16,
            fontWeight: FontWeight.bold,
          ),
        ),
        Row(
          children: [
            Radio<String>(
              value: 'male',
              groupValue: _selectedGender,
              onChanged: (String? value) {
                setState(() {
                  _selectedGender = value!;
                });
              },
            ),
            const Text('Male'),
            Radio<String>(
              value: 'female',
              groupValue: _selectedGender,
              onChanged: (String? value) {
                setState(() {
                  _selectedGender = value!;
                });
              },
            ),
            const Text('Female'),
            Radio<String>(
              value: 'other',
              groupValue: _selectedGender,
              onChanged: (String? value) {
                setState(() {
                  _selectedGender = value!;
                });
              },
            ),
            const Text('Other'),
          ],
        ),
      ],
    );
  }
}

Key Radio properties:

  • value -- The value this Radio represents.
  • groupValue -- The currently selected value in the group. When value == groupValue, this Radio is selected.
  • onChanged -- Callback when this Radio is selected.
  • activeColor -- Color when selected.
  • toggleable -- If true, tapping the selected radio deselects it (sets groupValue to null).

RadioListTile

RadioListTile combines a Radio button with a ListTile for a larger tappable area and better layout:

RadioListTile Example

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

  @override
  State<ThemeSelector> createState() => _ThemeSelectorState();
}

class _ThemeSelectorState extends State<ThemeSelector> {
  String _selectedTheme = 'system';

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.all(16),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          const Padding(
            padding: EdgeInsets.all(16),
            child: Text(
              'App Theme',
              style: TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
          RadioListTile<String>(
            title: const Text('Light'),
            subtitle: const Text('Always use light theme'),
            secondary: const Icon(Icons.light_mode),
            value: 'light',
            groupValue: _selectedTheme,
            onChanged: (value) {
              setState(() => _selectedTheme = value!);
            },
          ),
          RadioListTile<String>(
            title: const Text('Dark'),
            subtitle: const Text('Always use dark theme'),
            secondary: const Icon(Icons.dark_mode),
            value: 'dark',
            groupValue: _selectedTheme,
            onChanged: (value) {
              setState(() => _selectedTheme = value!);
            },
          ),
          RadioListTile<String>(
            title: const Text('System'),
            subtitle: const Text('Follow device setting'),
            secondary: const Icon(Icons.settings_suggest),
            value: 'system',
            groupValue: _selectedTheme,
            onChanged: (value) {
              setState(() => _selectedTheme = value!);
            },
          ),
        ],
      ),
    );
  }
}

Using Enums with Radio

Using enums with Radio widgets is a cleaner and type-safe approach:

Radio with Enums

enum ShippingMethod { standard, express, overnight }

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

  @override
  State<ShippingSelector> createState() =>
      _ShippingSelectorState();
}

class _ShippingSelectorState extends State<ShippingSelector> {
  ShippingMethod _method = ShippingMethod.standard;

  String _getLabel(ShippingMethod method) {
    switch (method) {
      case ShippingMethod.standard:
        return 'Standard (5-7 days)';
      case ShippingMethod.express:
        return 'Express (2-3 days)';
      case ShippingMethod.overnight:
        return 'Overnight (next day)';
    }
  }

  String _getPrice(ShippingMethod method) {
    switch (method) {
      case ShippingMethod.standard:
        return 'Free';
      case ShippingMethod.express:
        return '\$9.99';
      case ShippingMethod.overnight:
        return '\$19.99';
    }
  }

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.all(16),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: ShippingMethod.values.map((method) {
          return RadioListTile<ShippingMethod>(
            title: Text(_getLabel(method)),
            subtitle: Text(_getPrice(method)),
            value: method,
            groupValue: _method,
            onChanged: (value) {
              setState(() => _method = value!);
            },
          );
        }).toList(),
      ),
    );
  }
}
Tip: Always use enums instead of strings for Radio group values. Enums provide compile-time safety, prevent typos, and make your code easier to refactor. Dart enums can also have methods and properties (enhanced enums) for even cleaner code.

Practical Example: Settings Page

Complete Settings Page

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

  @override
  State<SettingsPage> createState() => _SettingsPageState();
}

class _SettingsPageState extends State<SettingsPage> {
  bool _darkMode = false;
  bool _notifications = true;
  double _fontSize = 16;
  String _language = 'en';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Settings')),
      body: ListView(
        children: [
          // Toggle section
          const _SectionHeader(title: 'General'),
          SwitchListTile(
            secondary: const Icon(Icons.dark_mode),
            title: const Text('Dark Mode'),
            value: _darkMode,
            onChanged: (v) => setState(() => _darkMode = v),
          ),
          SwitchListTile(
            secondary: const Icon(Icons.notifications),
            title: const Text('Notifications'),
            value: _notifications,
            onChanged: (v) => setState(() => _notifications = v),
          ),

          // Slider section
          const _SectionHeader(title: 'Display'),
          ListTile(
            leading: const Icon(Icons.text_fields),
            title: const Text('Font Size'),
            subtitle: Slider(
              value: _fontSize,
              min: 12,
              max: 24,
              divisions: 6,
              label: '\${_fontSize.round()} pt',
              onChanged: (v) => setState(() => _fontSize = v),
            ),
            trailing: Text('\${_fontSize.round()}'),
          ),

          // Radio section
          const _SectionHeader(title: 'Language'),
          RadioListTile<String>(
            title: const Text('English'),
            value: 'en',
            groupValue: _language,
            onChanged: (v) => setState(() => _language = v!),
          ),
          RadioListTile<String>(
            title: const Text('Arabic'),
            value: 'ar',
            groupValue: _language,
            onChanged: (v) => setState(() => _language = v!),
          ),
          RadioListTile<String>(
            title: const Text('Spanish'),
            value: 'es',
            groupValue: _language,
            onChanged: (v) => setState(() => _language = v!),
          ),
        ],
      ),
    );
  }
}

class _SectionHeader extends StatelessWidget {
  final String title;
  const _SectionHeader({required this.title});

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
      child: Text(
        title,
        style: TextStyle(
          fontSize: 14,
          fontWeight: FontWeight.bold,
          color: Colors.grey[600],
        ),
      ),
    );
  }
}

Practical Example: Filter Controls

Product Filter Panel

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

  @override
  State<FilterPanel> createState() => _FilterPanelState();
}

class _FilterPanelState extends State<FilterPanel> {
  RangeValues _priceRange = const RangeValues(10, 90);
  double _rating = 3;
  String _sortBy = 'relevance';
  bool _inStock = true;
  bool _onSale = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Filters')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          // Price range
          const Text(
            'Price Range',
            style: TextStyle(fontWeight: FontWeight.bold),
          ),
          Text(
            '\$\${_priceRange.start.round()} - '
            '\$\${_priceRange.end.round()}',
          ),
          RangeSlider(
            values: _priceRange,
            min: 0,
            max: 100,
            divisions: 20,
            labels: RangeLabels(
              '\$\${_priceRange.start.round()}',
              '\$\${_priceRange.end.round()}',
            ),
            onChanged: (v) => setState(() => _priceRange = v),
          ),
          const SizedBox(height: 16),

          // Minimum rating
          const Text(
            'Minimum Rating',
            style: TextStyle(fontWeight: FontWeight.bold),
          ),
          Row(
            children: [
              Expanded(
                child: Slider(
                  value: _rating,
                  min: 1,
                  max: 5,
                  divisions: 4,
                  label: '\${_rating.round()} stars',
                  onChanged: (v) => setState(() => _rating = v),
                ),
              ),
              Text('\${_rating.round()} stars'),
            ],
          ),
          const SizedBox(height: 16),

          // Sort options
          const Text(
            'Sort By',
            style: TextStyle(fontWeight: FontWeight.bold),
          ),
          RadioListTile<String>(
            title: const Text('Relevance'),
            value: 'relevance',
            groupValue: _sortBy,
            onChanged: (v) => setState(() => _sortBy = v!),
            dense: true,
          ),
          RadioListTile<String>(
            title: const Text('Price: Low to High'),
            value: 'price_asc',
            groupValue: _sortBy,
            onChanged: (v) => setState(() => _sortBy = v!),
            dense: true,
          ),
          RadioListTile<String>(
            title: const Text('Price: High to Low'),
            value: 'price_desc',
            groupValue: _sortBy,
            onChanged: (v) => setState(() => _sortBy = v!),
            dense: true,
          ),
          const SizedBox(height: 16),

          // Toggle filters
          SwitchListTile(
            title: const Text('In Stock Only'),
            value: _inStock,
            onChanged: (v) => setState(() => _inStock = v),
          ),
          SwitchListTile(
            title: const Text('On Sale'),
            value: _onSale,
            onChanged: (v) => setState(() => _onSale = v),
          ),
        ],
      ),
      bottomNavigationBar: Padding(
        padding: const EdgeInsets.all(16),
        child: ElevatedButton(
          onPressed: () {
            debugPrint('Applying filters...');
          },
          style: ElevatedButton.styleFrom(
            padding: const EdgeInsets.symmetric(vertical: 16),
          ),
          child: const Text('Apply Filters'),
        ),
      ),
    );
  }
}
Warning: When using Slider inside a horizontal Row or a constrained width, always wrap it with Expanded or give it a fixed width. Without constraints, the Slider will try to take infinite width and cause a layout error.

Practical Example: Preference Form

User Preference Form

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

  @override
  State<PreferenceForm> createState() => _PreferenceFormState();
}

class _PreferenceFormState extends State<PreferenceForm> {
  bool _newsletter = true;
  bool _autoplay = false;
  double _brightness = 0.7;
  String _quality = 'hd';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Preferences')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          Card(
            child: Column(
              children: [
                SwitchListTile(
                  secondary: const Icon(Icons.mail),
                  title: const Text('Newsletter'),
                  subtitle: const Text('Weekly updates'),
                  value: _newsletter,
                  onChanged: (v) =>
                      setState(() => _newsletter = v),
                ),
                const Divider(height: 1),
                SwitchListTile(
                  secondary: const Icon(Icons.play_arrow),
                  title: const Text('Autoplay Videos'),
                  value: _autoplay,
                  onChanged: (v) =>
                      setState(() => _autoplay = v),
                ),
              ],
            ),
          ),
          const SizedBox(height: 16),
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Row(
                    mainAxisAlignment:
                        MainAxisAlignment.spaceBetween,
                    children: [
                      const Text(
                        'Brightness',
                        style: TextStyle(
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                      Text(
                        '\${(_brightness * 100).round()}%',
                      ),
                    ],
                  ),
                  Slider(
                    value: _brightness,
                    onChanged: (v) =>
                        setState(() => _brightness = v),
                  ),
                ],
              ),
            ),
          ),
          const SizedBox(height: 16),
          Card(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                const Padding(
                  padding: EdgeInsets.all(16),
                  child: Text(
                    'Video Quality',
                    style: TextStyle(
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
                RadioListTile<String>(
                  title: const Text('Auto'),
                  subtitle: const Text(
                      'Adjusts based on connection'),
                  value: 'auto',
                  groupValue: _quality,
                  onChanged: (v) =>
                      setState(() => _quality = v!),
                ),
                RadioListTile<String>(
                  title: const Text('HD (720p)'),
                  value: 'hd',
                  groupValue: _quality,
                  onChanged: (v) =>
                      setState(() => _quality = v!),
                ),
                RadioListTile<String>(
                  title: const Text('Full HD (1080p)'),
                  value: 'fhd',
                  groupValue: _quality,
                  onChanged: (v) =>
                      setState(() => _quality = v!),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Practice Exercise

Build a complete preference screen with all three widget types: (1) A Switch section with three SwitchListTiles where the second and third toggles are disabled when the first is off. (2) A Slider section with both a regular Slider (for a single value like volume) and a RangeSlider (for a range like price filter), both with divisions and labels. (3) A Radio section using enums with RadioListTile for selecting a theme (Light, Dark, System). (4) An "Apply" button at the bottom that prints all current values. Challenge: Use CupertinoSwitch for one toggle and add a custom styled Slider with SliderTheme.