Flutter ChoiceChip Localization: Single-Select Options for Multilingual Apps
ChoiceChip is a Flutter Material widget that represents a single selection from a group of options. In multilingual applications, ChoiceChip is essential for presenting translated option labels in a compact single-select group, 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 ChoiceChip in Localization Context
ChoiceChip renders a Material Design chip that indicates selection with a checkmark and elevated styling. Only one ChoiceChip in a group should be selected at a time. For multilingual apps, this enables:
- Translated option labels in a single-select chip group
- Compact layout that wraps to fit available width with any translation
- RTL-aware chip flow using
Wrapthat reverses automatically - Clear visual selection feedback regardless of language
Why ChoiceChip Matters for Multilingual Apps
ChoiceChip provides:
- Single-select: Exactly one translated option is active at a time
- Visual distinction: Selected chip uses a distinct color and checkmark
- Compact options: Multiple translated choices in less space than radio buttons
- Accessibility: Selection state announced in the active language
Basic ChoiceChip Implementation
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedChoiceChipExample extends StatefulWidget {
const LocalizedChoiceChipExample({super.key});
@override
State<LocalizedChoiceChipExample> createState() =>
_LocalizedChoiceChipExampleState();
}
class _LocalizedChoiceChipExampleState
extends State<LocalizedChoiceChipExample> {
String _selectedSize = 'medium';
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final sizes = {
'small': l10n.smallLabel,
'medium': l10n.mediumLabel,
'large': l10n.largeLabel,
'extra_large': l10n.extraLargeLabel,
};
return Scaffold(
appBar: AppBar(title: Text(l10n.selectSizeTitle)),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.sizeLabel,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: sizes.entries.map((entry) {
return ChoiceChip(
label: Text(entry.value),
selected: _selectedSize == entry.key,
onSelected: (selected) {
if (selected) {
setState(() => _selectedSize = entry.key);
}
},
);
}).toList(),
),
const SizedBox(height: 16),
Text(
l10n.selectedSizeMessage(sizes[_selectedSize]!),
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
);
}
}
Advanced ChoiceChip Patterns for Localization
Category Selector with Icons
ChoiceChips with leading icons for visual context alongside translated category names.
class CategoryChoiceChips extends StatefulWidget {
const CategoryChoiceChips({super.key});
@override
State<CategoryChoiceChips> createState() => _CategoryChoiceChipsState();
}
class _CategoryChoiceChipsState extends State<CategoryChoiceChips> {
String _selectedCategory = 'all';
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final categories = [
_Category('all', l10n.allCategoriesLabel, Icons.apps),
_Category('food', l10n.foodCategoryLabel, Icons.restaurant),
_Category('drinks', l10n.drinksCategoryLabel, Icons.local_cafe),
_Category('desserts', l10n.dessertsCategoryLabel, Icons.cake),
_Category('snacks', l10n.snacksCategoryLabel, Icons.fastfood),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsetsDirectional.fromSTEB(16, 16, 16, 8),
child: Text(
l10n.menuCategoriesLabel,
style: Theme.of(context).textTheme.titleSmall,
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsetsDirectional.only(start: 16),
child: Row(
children: categories.map((category) {
return Padding(
padding: const EdgeInsetsDirectional.only(end: 8),
child: ChoiceChip(
avatar: Icon(category.icon, size: 18),
label: Text(category.label),
selected: _selectedCategory == category.key,
onSelected: (selected) {
if (selected) {
setState(() => _selectedCategory = category.key);
}
},
),
);
}).toList(),
),
),
],
);
}
}
class _Category {
final String key;
final String label;
final IconData icon;
_Category(this.key, this.label, this.icon);
}
Sort Order Selector
ChoiceChips for selecting a sort order with translated sort options.
class SortOrderChoiceChips extends StatefulWidget {
const SortOrderChoiceChips({super.key});
@override
State<SortOrderChoiceChips> createState() => _SortOrderChoiceChipsState();
}
class _SortOrderChoiceChipsState extends State<SortOrderChoiceChips> {
String _selectedSort = 'relevance';
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final sortOptions = {
'relevance': l10n.sortByRelevanceLabel,
'newest': l10n.sortByNewestLabel,
'price_low': l10n.sortByPriceLowLabel,
'price_high': l10n.sortByPriceHighLabel,
'rating': l10n.sortByRatingLabel,
};
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.sortByLabel,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: sortOptions.entries.map((entry) {
return ChoiceChip(
label: Text(entry.value),
selected: _selectedSort == entry.key,
onSelected: (selected) {
if (selected) {
setState(() => _selectedSort = entry.key);
}
},
);
}).toList(),
),
],
),
);
}
}
Time Period Selector
ChoiceChips for selecting a time period with translated duration labels.
class TimePeriodChoiceChips extends StatefulWidget {
const TimePeriodChoiceChips({super.key});
@override
State<TimePeriodChoiceChips> createState() => _TimePeriodChoiceChipsState();
}
class _TimePeriodChoiceChipsState extends State<TimePeriodChoiceChips> {
String _selectedPeriod = '7d';
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final periods = {
'24h': l10n.last24HoursLabel,
'7d': l10n.last7DaysLabel,
'30d': l10n.last30DaysLabel,
'90d': l10n.last90DaysLabel,
'1y': l10n.lastYearLabel,
'all': l10n.allTimeLabel,
};
return Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.timePeriodLabel,
style: Theme.of(context).textTheme.titleMedium,
),
Icon(
Icons.calendar_today,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 20,
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 4,
children: periods.entries.map((entry) {
return ChoiceChip(
label: Text(entry.value),
selected: _selectedPeriod == entry.key,
onSelected: (selected) {
if (selected) {
setState(() => _selectedPeriod = entry.key);
}
},
);
}).toList(),
),
],
),
),
);
}
}
RTL Support and Bidirectional Layouts
ChoiceChip works correctly in RTL layouts. When placed inside a Wrap, chips flow from right to left. The checkmark and avatar icons position correctly based on text direction.
class BidirectionalChoiceChips extends StatefulWidget {
const BidirectionalChoiceChips({super.key});
@override
State<BidirectionalChoiceChips> createState() =>
_BidirectionalChoiceChipsState();
}
class _BidirectionalChoiceChipsState extends State<BidirectionalChoiceChips> {
String _selected = 'standard';
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final options = {
'express': l10n.expressShippingLabel,
'standard': l10n.standardShippingLabel,
'economy': l10n.economyShippingLabel,
};
return Padding(
padding: const EdgeInsetsDirectional.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.shippingMethodLabel,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: options.entries.map((entry) {
return ChoiceChip(
label: Text(entry.value),
selected: _selected == entry.key,
onSelected: (selected) {
if (selected) {
setState(() => _selected = entry.key);
}
},
);
}).toList(),
),
],
),
);
}
}
Testing ChoiceChip 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 LocalizedChoiceChipExample(),
);
}
testWidgets('ChoiceChip renders localized labels', (tester) async {
await tester.pumpWidget(buildTestWidget());
await tester.pumpAndSettle();
expect(find.byType(ChoiceChip), findsWidgets);
});
testWidgets('ChoiceChip selection changes', (tester) async {
await tester.pumpWidget(buildTestWidget());
await tester.pumpAndSettle();
final chip = find.byType(ChoiceChip).first;
await tester.tap(chip);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('ChoiceChip works in RTL', (tester) async {
await tester.pumpWidget(buildTestWidget(locale: const Locale('ar')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
}
Best Practices
Enforce single selection by only setting
selected: trueon one ChoiceChip at a time, updating state when any chip is tapped.Use
Wrapfor responsive layouts so ChoiceChips flow to the next line when translated labels are wider than the screen.Provide
avataricons alongside translated labels for visual context that helps users identify options without relying on text alone.Show the selected value in a translated confirmation message using a parameterized label like
selectedSizeMessage(size).Use horizontal
SingleChildScrollViewfor single-row chip strips (like category tabs) where vertical wrapping isn't appropriate.Test with verbose languages to verify chips wrap correctly and the selected state is visually clear at all sizes.
Conclusion
ChoiceChip provides a compact single-select option widget for Flutter apps. For multilingual apps, it handles translated option labels with clear visual selection feedback, wraps correctly in RTL layouts, and works naturally with Wrap for responsive chip groups. By combining ChoiceChip with category selectors, sort controls, and time period pickers, you can build compact selection interfaces that communicate clearly in every supported language.