Flutter FilterChip Localization: Multi-Select Filters for Multilingual Apps
FilterChip is a Flutter Material widget that represents a filter option in a group, allowing users to select multiple criteria simultaneously. In multilingual applications, FilterChip is essential for displaying translated filter labels that users can toggle on and off, handling variable chip widths for translations of different lengths, supporting RTL chip flow that wraps correctly from right to left, and providing accessible selection announcements in the active language.
Understanding FilterChip in Localization Context
FilterChip renders a Material Design chip with a checkmark that appears when selected. For multilingual apps, this enables:
- Translated filter labels in a compact, wrappable layout
- Multi-select filtering with localized category names
- RTL-aware chip flow using
Wrapthat reverses automatically - Active filter counts with parameterized translated labels
Why FilterChip Matters for Multilingual Apps
FilterChip provides:
- Multi-select: Users can activate multiple translated filters simultaneously
- Visual feedback: A checkmark appears on selected chips in all languages
- Compact layout: Chips wrap to fit available width regardless of translation length
- Accessibility: Selected state is announced in the active language by screen readers
Basic FilterChip Implementation
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedFilterChipExample extends StatefulWidget {
const LocalizedFilterChipExample({super.key});
@override
State<LocalizedFilterChipExample> createState() =>
_LocalizedFilterChipExampleState();
}
class _LocalizedFilterChipExampleState
extends State<LocalizedFilterChipExample> {
final Set<String> _selectedFilters = {};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final filters = {
'electronics': l10n.electronicsCategory,
'clothing': l10n.clothingCategory,
'books': l10n.booksCategory,
'sports': l10n.sportsCategory,
'home': l10n.homeCategory,
'toys': l10n.toysCategory,
};
return Scaffold(
appBar: AppBar(title: Text(l10n.filterProductsTitle)),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.categoriesLabel,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: filters.entries.map((entry) {
final isSelected = _selectedFilters.contains(entry.key);
return FilterChip(
label: Text(entry.value),
selected: isSelected,
onSelected: (selected) {
setState(() {
if (selected) {
_selectedFilters.add(entry.key);
} else {
_selectedFilters.remove(entry.key);
}
});
},
);
}).toList(),
),
const SizedBox(height: 16),
Text(
_selectedFilters.isEmpty
? l10n.noFiltersApplied
: l10n.activeFiltersCount(_selectedFilters.length),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
}
Advanced FilterChip Patterns for Localization
Grouped Filters with Section Headers
FilterChips organized into localized groups with translated section headers.
class GroupedFilterChips extends StatefulWidget {
const GroupedFilterChips({super.key});
@override
State<GroupedFilterChips> createState() => _GroupedFilterChipsState();
}
class _GroupedFilterChipsState extends State<GroupedFilterChips> {
final Set<String> _selectedSizes = {};
final Set<String> _selectedColors = {};
final Set<String> _selectedBrands = {};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_FilterSection(
title: l10n.sizeLabel,
filters: {
'xs': l10n.extraSmallLabel,
's': l10n.smallLabel,
'm': l10n.mediumLabel,
'l': l10n.largeLabel,
'xl': l10n.extraLargeLabel,
},
selected: _selectedSizes,
onChanged: (key, selected) {
setState(() {
selected
? _selectedSizes.add(key)
: _selectedSizes.remove(key);
});
},
),
const SizedBox(height: 16),
_FilterSection(
title: l10n.colorLabel,
filters: {
'red': l10n.redColor,
'blue': l10n.blueColor,
'green': l10n.greenColor,
'black': l10n.blackColor,
'white': l10n.whiteColor,
},
selected: _selectedColors,
onChanged: (key, selected) {
setState(() {
selected
? _selectedColors.add(key)
: _selectedColors.remove(key);
});
},
),
const SizedBox(height: 16),
_FilterSection(
title: l10n.brandLabel,
filters: {
'brand_a': l10n.brandALabel,
'brand_b': l10n.brandBLabel,
'brand_c': l10n.brandCLabel,
},
selected: _selectedBrands,
onChanged: (key, selected) {
setState(() {
selected
? _selectedBrands.add(key)
: _selectedBrands.remove(key);
});
},
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () {
setState(() {
_selectedSizes.clear();
_selectedColors.clear();
_selectedBrands.clear();
});
},
icon: const Icon(Icons.clear_all),
label: Text(l10n.clearAllFiltersLabel),
),
],
),
);
}
}
class _FilterSection extends StatelessWidget {
final String title;
final Map<String, String> filters;
final Set<String> selected;
final void Function(String key, bool selected) onChanged;
const _FilterSection({
required this.title,
required this.filters,
required this.selected,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: filters.entries.map((entry) {
return FilterChip(
label: Text(entry.value),
selected: selected.contains(entry.key),
onSelected: (value) => onChanged(entry.key, value),
);
}).toList(),
),
],
);
}
}
Filter Chips with Icons and Counts
FilterChips that show translated labels with leading icons and result counts.
class FilterChipsWithCounts extends StatefulWidget {
const FilterChipsWithCounts({super.key});
@override
State<FilterChipsWithCounts> createState() => _FilterChipsWithCountsState();
}
class _FilterChipsWithCountsState extends State<FilterChipsWithCounts> {
final Set<String> _selectedStatuses = {};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final statusFilters = [
_FilterItem(
key: 'active',
label: l10n.activeStatus,
icon: Icons.check_circle_outline,
count: 24,
),
_FilterItem(
key: 'pending',
label: l10n.pendingStatus,
icon: Icons.schedule,
count: 8,
),
_FilterItem(
key: 'completed',
label: l10n.completedStatus,
icon: Icons.done_all,
count: 156,
),
_FilterItem(
key: 'cancelled',
label: l10n.cancelledStatus,
icon: Icons.cancel_outlined,
count: 3,
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsetsDirectional.fromSTEB(16, 16, 16, 8),
child: Text(
l10n.filterByStatusLabel,
style: Theme.of(context).textTheme.titleSmall,
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsetsDirectional.only(start: 16),
child: Row(
children: statusFilters.map((filter) {
final isSelected = _selectedStatuses.contains(filter.key);
return Padding(
padding: const EdgeInsetsDirectional.only(end: 8),
child: FilterChip(
avatar: Icon(filter.icon, size: 18),
label: Text('${filter.label} (${filter.count})'),
selected: isSelected,
onSelected: (selected) {
setState(() {
selected
? _selectedStatuses.add(filter.key)
: _selectedStatuses.remove(filter.key);
});
},
),
);
}).toList(),
),
),
],
);
}
}
class _FilterItem {
final String key;
final String label;
final IconData icon;
final int count;
_FilterItem({
required this.key,
required this.label,
required this.icon,
required this.count,
});
}
Bottom Sheet Filter Panel
A modal bottom sheet with organized FilterChips for a complete filter experience.
class FilterBottomSheet extends StatefulWidget {
const FilterBottomSheet({super.key});
@override
State<FilterBottomSheet> createState() => _FilterBottomSheetState();
}
class _FilterBottomSheetState extends State<FilterBottomSheet> {
final Set<String> _priceRanges = {};
final Set<String> _ratings = {};
void _showFilterSheet() {
final l10n = AppLocalizations.of(context)!;
showModalBottomSheet(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setSheetState) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.filtersTitle,
style: Theme.of(context).textTheme.titleLarge,
),
TextButton(
onPressed: () {
setSheetState(() {
_priceRanges.clear();
_ratings.clear();
});
setState(() {});
},
child: Text(l10n.resetLabel),
),
],
),
const SizedBox(height: 16),
Text(
l10n.priceRangeLabel,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
_buildPriceChip(setSheetState, 'under25', l10n.under25Label),
_buildPriceChip(setSheetState, '25to50', l10n.range25to50Label),
_buildPriceChip(setSheetState, '50to100', l10n.range50to100Label),
_buildPriceChip(setSheetState, 'over100', l10n.over100Label),
],
),
const SizedBox(height: 16),
Text(
l10n.ratingLabel,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
_buildRatingChip(setSheetState, '4plus', l10n.fourStarsAndUp),
_buildRatingChip(setSheetState, '3plus', l10n.threeStarsAndUp),
_buildRatingChip(setSheetState, '2plus', l10n.twoStarsAndUp),
],
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.applyFiltersLabel),
),
),
],
),
);
},
);
},
);
}
FilterChip _buildPriceChip(
StateSetter setSheetState, String key, String label) {
return FilterChip(
label: Text(label),
selected: _priceRanges.contains(key),
onSelected: (selected) {
setSheetState(() {
selected ? _priceRanges.add(key) : _priceRanges.remove(key);
});
setState(() {});
},
);
}
FilterChip _buildRatingChip(
StateSetter setSheetState, String key, String label) {
return FilterChip(
label: Text(label),
selected: _ratings.contains(key),
onSelected: (selected) {
setSheetState(() {
selected ? _ratings.add(key) : _ratings.remove(key);
});
setState(() {});
},
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final totalFilters = _priceRanges.length + _ratings.length;
return Scaffold(
appBar: AppBar(
title: Text(l10n.productsTitle),
actions: [
Badge(
isLabelVisible: totalFilters > 0,
label: Text('$totalFilters'),
child: IconButton(
icon: const Icon(Icons.filter_list),
tooltip: l10n.filtersTooltip,
onPressed: _showFilterSheet,
),
),
],
),
body: const Center(child: Placeholder()),
);
}
}
RTL Support and Bidirectional Layouts
FilterChip works correctly in RTL layouts. When placed inside a Wrap, chips flow from right to left automatically. The checkmark icon also positions correctly.
class BidirectionalFilterChips extends StatefulWidget {
const BidirectionalFilterChips({super.key});
@override
State<BidirectionalFilterChips> createState() =>
_BidirectionalFilterChipsState();
}
class _BidirectionalFilterChipsState extends State<BidirectionalFilterChips> {
final Set<String> _selected = {};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final options = {
'new': l10n.newArrivalLabel,
'sale': l10n.onSaleLabel,
'popular': l10n.popularLabel,
'free_shipping': l10n.freeShippingLabel,
};
return Padding(
padding: const EdgeInsetsDirectional.all(16),
child: Wrap(
spacing: 8,
runSpacing: 4,
children: options.entries.map((entry) {
return FilterChip(
label: Text(entry.value),
selected: _selected.contains(entry.key),
onSelected: (selected) {
setState(() {
selected
? _selected.add(entry.key)
: _selected.remove(entry.key);
});
},
);
}).toList(),
),
);
}
}
Testing FilterChip Localization
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() {
Widget buildTestWidget({Locale locale = const Locale('en')}) {
return MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const LocalizedFilterChipExample(),
);
}
testWidgets('FilterChip renders localized labels', (tester) async {
await tester.pumpWidget(buildTestWidget());
await tester.pumpAndSettle();
expect(find.byType(FilterChip), findsWidgets);
});
testWidgets('FilterChip selection works', (tester) async {
await tester.pumpWidget(buildTestWidget());
await tester.pumpAndSettle();
final chip = find.byType(FilterChip).first;
await tester.tap(chip);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('FilterChip works in RTL', (tester) async {
await tester.pumpWidget(buildTestWidget(locale: const Locale('ar')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
}
Best Practices
Use
Wrapto layout FilterChips so they flow to the next line when translated labels make them wider than the available space.Show active filter count with a parameterized translated label like
activeFiltersCount(n)so users know how many filters are applied.Provide a "Clear all" action with a translated label to let users reset all filters at once.
Group related filters under translated section headers to organize complex filter panels.
Use
SingleChildScrollViewwith horizontal scroll for single-row filter strips where vertical wrapping isn't appropriate.Test with verbose languages (German, Finnish) to verify chips wrap correctly and don't overflow their container.
Conclusion
FilterChip provides a multi-select filter widget for Flutter apps. For multilingual apps, it handles translated filter labels with checkmark selection, wraps correctly in RTL layouts, and combines naturally with Wrap for responsive chip flows. By combining FilterChip with grouped sections, icon-labeled filters, and bottom sheet panels, you can build filter experiences that communicate clearly in every supported language.