Flutter Widgets Fundamentals

Material Widgets Deep Dive

50 min Lesson 12 of 18

Exploring Material Design Widgets

Flutter ships with a rich collection of Material Design widgets beyond the basics. In this lesson you will explore Chips, Tooltips, Badges, Progress Indicators, Steppers, DataTables, and Expansion widgets. These components help you build polished, feature-rich interfaces without relying on third-party packages.

Chip Widgets

Chips are compact elements that represent attributes, actions, or filters. Flutter provides five chip variants, each designed for a specific use case.

Basic Chip

A read-only chip that displays information such as a tag or label.

Basic Chip

Chip(
  avatar: const CircleAvatar(
    backgroundColor: Colors.blue,
    child: Text('F'),
  ),
  label: const Text('Flutter'),
  onDeleted: () {
    debugPrint('Chip deleted');
  },
  deleteIcon: const Icon(Icons.close, size: 18),
)

ActionChip

A chip that triggers an action when tapped, similar to a compact button.

ActionChip Example

ActionChip(
  avatar: const Icon(Icons.directions_car, size: 18),
  label: const Text('Get Directions'),
  onPressed: () {
    debugPrint('Opening maps...');
  },
)

FilterChip

A chip with a checkmark that toggles a filter on or off. Useful for multi-select scenarios.

FilterChip with State

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

  @override
  State<FilterChipExample> createState() => _FilterChipExampleState();
}

class _FilterChipExampleState extends State<FilterChipExample> {
  final Map<String, bool> _filters = {
    'Dart': false,
    'Flutter': true,
    'Firebase': false,
  };

  @override
  Widget build(BuildContext context) {
    return Wrap(
      spacing: 8,
      children: _filters.entries.map((entry) {
        return FilterChip(
          label: Text(entry.key),
          selected: entry.value,
          onSelected: (bool selected) {
            setState(() => _filters[entry.key] = selected);
          },
          selectedColor: Colors.blue.shade100,
          checkmarkColor: Colors.blue,
        );
      }).toList(),
    );
  }
}

ChoiceChip

Similar to a radio button -- only one chip can be selected at a time.

ChoiceChip for Single Selection

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

  @override
  State<ChoiceChipExample> createState() => _ChoiceChipExampleState();
}

class _ChoiceChipExampleState extends State<ChoiceChipExample> {
  int _selectedIndex = 0;
  final List<String> _sizes = ['Small', 'Medium', 'Large'];

  @override
  Widget build(BuildContext context) {
    return Wrap(
      spacing: 8,
      children: List.generate(_sizes.length, (index) {
        return ChoiceChip(
          label: Text(_sizes[index]),
          selected: _selectedIndex == index,
          onSelected: (bool selected) {
            setState(() => _selectedIndex = index);
          },
        );
      }),
    );
  }
}

InputChip

A chip that represents a complex piece of information like a contact. It can be selected, deleted, and pressed.

InputChip for Tags

InputChip(
  avatar: const CircleAvatar(
    backgroundImage: NetworkImage('https://example.com/avatar.png'),
  ),
  label: const Text('Ahmed'),
  selected: true,
  onSelected: (bool selected) {
    debugPrint('Selected: \$selected');
  },
  onDeleted: () {
    debugPrint('Removed Ahmed');
  },
  onPressed: () {
    debugPrint('Tapped on Ahmed');
  },
)
Tip: Use Wrap as the parent of chip lists. It automatically flows chips to the next line when the row runs out of space, which is exactly the layout behaviour you want for tag-like components.

Tooltip

A Tooltip displays a short text label when the user long-presses or hovers over a widget. Accessibility tools use tooltips to describe UI elements.

Tooltip Example

Tooltip(
  message: 'Add a new item to your list',
  preferBelow: false,
  showDuration: const Duration(seconds: 2),
  child: IconButton(
    icon: const Icon(Icons.add),
    onPressed: () {
      debugPrint('Add pressed');
    },
  ),
)

Badge

The Badge widget (introduced in Flutter 3.7) overlays a small label on another widget, commonly used for notification counts.

Badge on Icon

Badge(
  label: const Text('3'),
  child: const Icon(Icons.notifications, size: 30),
)

// Badge without label (dot indicator)
Badge(
  smallSize: 8,
  child: const Icon(Icons.mail, size: 30),
)

Progress Indicators

Flutter provides two progress indicators for showing loading or operation status.

CircularProgressIndicator

Circular Progress

// Indeterminate (spinning)
const CircularProgressIndicator()

// Determinate (shows specific progress)
CircularProgressIndicator(
  value: 0.7, // 70% complete
  strokeWidth: 6,
  backgroundColor: Colors.grey.shade300,
  valueColor: const AlwaysStoppedAnimation<Color>(Colors.blue),
)

LinearProgressIndicator

Linear Progress

// Indeterminate
const LinearProgressIndicator()

// Determinate with styling
LinearProgressIndicator(
  value: 0.45,
  minHeight: 8,
  backgroundColor: Colors.grey.shade200,
  borderRadius: BorderRadius.circular(4),
  valueColor: const AlwaysStoppedAnimation<Color>(Colors.green),
)
Note: Use the indeterminate form (no value) when you do not know how long the operation will take. Use the determinate form (with a value between 0.0 and 1.0) when you can track progress, like a file upload.

Stepper

The Stepper widget guides users through a sequence of steps, like a form wizard.

Stepper Example

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

  @override
  State<StepperExample> createState() => _StepperExampleState();
}

class _StepperExampleState extends State<StepperExample> {
  int _currentStep = 0;

  @override
  Widget build(BuildContext context) {
    return Stepper(
      currentStep: _currentStep,
      onStepContinue: () {
        if (_currentStep < 2) {
          setState(() => _currentStep++);
        }
      },
      onStepCancel: () {
        if (_currentStep > 0) {
          setState(() => _currentStep--);
        }
      },
      onStepTapped: (int step) {
        setState(() => _currentStep = step);
      },
      steps: const [
        Step(
          title: Text('Account'),
          content: Text('Enter your email and password.'),
          isActive: true,
        ),
        Step(
          title: Text('Profile'),
          content: Text('Set up your profile information.'),
        ),
        Step(
          title: Text('Confirm'),
          content: Text('Review and submit your details.'),
        ),
      ],
    );
  }
}

DataTable

DataTable displays tabular data with sorting, selection, and row actions.

DataTable with Sorting

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

  @override
  State<DataTableExample> createState() => _DataTableExampleState();
}

class _DataTableExampleState extends State<DataTableExample> {
  bool _sortAscending = true;
  int _sortColumnIndex = 0;

  final List<Map<String, dynamic>> _data = [
    {'name': 'Flutter', 'stars': 162000, 'language': 'Dart'},
    {'name': 'React Native', 'stars': 115000, 'language': 'JavaScript'},
    {'name': 'Kotlin MP', 'stars': 48000, 'language': 'Kotlin'},
  ];

  void _sort(int columnIndex, bool ascending) {
    setState(() {
      _sortColumnIndex = columnIndex;
      _sortAscending = ascending;
      if (columnIndex == 1) {
        _data.sort((a, b) => ascending
            ? a['stars'].compareTo(b['stars'])
            : b['stars'].compareTo(a['stars']));
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      scrollDirection: Axis.horizontal,
      child: DataTable(
        sortColumnIndex: _sortColumnIndex,
        sortAscending: _sortAscending,
        columns: [
          const DataColumn(label: Text('Framework')),
          DataColumn(
            label: const Text('Stars'),
            numeric: true,
            onSort: _sort,
          ),
          const DataColumn(label: Text('Language')),
        ],
        rows: _data.map((item) {
          return DataRow(cells: [
            DataCell(Text(item['name'])),
            DataCell(Text(item['stars'].toString())),
            DataCell(Text(item['language'])),
          ]);
        }).toList(),
      ),
    );
  }
}

ExpansionTile

ExpansionTile creates a collapsible list item with a header that expands to reveal child content.

ExpansionTile Example

ExpansionTile(
  leading: const Icon(Icons.settings),
  title: const Text('Advanced Settings'),
  subtitle: const Text('Configure additional options'),
  initiallyExpanded: false,
  childrenPadding: const EdgeInsets.symmetric(horizontal: 16),
  children: const [
    ListTile(title: Text('Cache Size'), trailing: Text('256 MB')),
    ListTile(title: Text('Auto-Sync'), trailing: Text('Enabled')),
    ListTile(title: Text('Debug Mode'), trailing: Text('Off')),
  ],
)

ExpansionPanelList

For a group of panels where expanding one can optionally collapse the others.

ExpansionPanelList (Accordion)

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

  @override
  State<AccordionExample> createState() => _AccordionExampleState();
}

class _AccordionExampleState extends State<AccordionExample> {
  final List<bool> _isExpanded = [false, false, false];

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: ExpansionPanelList(
        expansionCallback: (int index, bool isExpanded) {
          setState(() => _isExpanded[index] = isExpanded);
        },
        children: [
          ExpansionPanel(
            headerBuilder: (context, isExpanded) {
              return const ListTile(title: Text('Section 1'));
            },
            body: const Padding(
              padding: EdgeInsets.all(16),
              child: Text('Content for section 1.'),
            ),
            isExpanded: _isExpanded[0],
          ),
          ExpansionPanel(
            headerBuilder: (context, isExpanded) {
              return const ListTile(title: Text('Section 2'));
            },
            body: const Padding(
              padding: EdgeInsets.all(16),
              child: Text('Content for section 2.'),
            ),
            isExpanded: _isExpanded[1],
          ),
          ExpansionPanel(
            headerBuilder: (context, isExpanded) {
              return const ListTile(title: Text('Section 3'));
            },
            body: const Padding(
              padding: EdgeInsets.all(16),
              child: Text('Content for section 3.'),
            ),
            isExpanded: _isExpanded[2],
          ),
        ],
      ),
    );
  }
}
Warning: ExpansionPanelList must be placed inside a scrollable widget like SingleChildScrollView or ListView because it does not handle overflow on its own. Forgetting this will cause layout errors when the panels expand beyond the available space.

Practical Example: Tag Selector

A complete tag selector that combines FilterChip with a search field.

Tag Selector Widget

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

  @override
  State<TagSelector> createState() => _TagSelectorState();
}

class _TagSelectorState extends State<TagSelector> {
  final List<String> _allTags = [
    'Flutter', 'Dart', 'Firebase', 'iOS',
    'Android', 'Web', 'Desktop', 'Testing',
  ];
  final Set<String> _selectedTags = {};

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          'Select Tags (\${_selectedTags.length} selected)',
          style: Theme.of(context).textTheme.titleMedium,
        ),
        const SizedBox(height: 12),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: _allTags.map((tag) {
            final isSelected = _selectedTags.contains(tag);
            return FilterChip(
              label: Text(tag),
              selected: isSelected,
              onSelected: (bool selected) {
                setState(() {
                  if (selected) {
                    _selectedTags.add(tag);
                  } else {
                    _selectedTags.remove(tag);
                  }
                });
              },
            );
          }).toList(),
        ),
      ],
    );
  }
}

Summary

  • Chips -- Chip, ActionChip, FilterChip, ChoiceChip, InputChip for tags, filters, and compact actions
  • Tooltip -- hover/long-press hint text for accessibility
  • Badge -- notification count or dot indicator overlay
  • ProgressIndicator -- circular and linear loading indicators (determinate and indeterminate)
  • Stepper -- guided multi-step wizard interface
  • DataTable -- tabular data display with sorting and selection
  • ExpansionTile / ExpansionPanelList -- collapsible content sections

Practice Exercise

Build a product filter screen. At the top, use ChoiceChip for selecting a category (Electronics, Clothing, Books). Below, use FilterChip for selecting brands. Add a LinearProgressIndicator that shows how many filters are active out of the total. Display the filtered results in a DataTable with product name, price, and rating columns.