Material Widgets Deep Dive
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');
},
)
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),
)
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],
),
],
),
);
}
}
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.