Flutter SegmentedButton Localization: Material 3 Toggle Groups and Selection Controls
SegmentedButton is a Material 3 widget that allows users to select from a set of options. Localizing segmented buttons ensures users worldwide can understand and interact with your selection controls effectively. This guide covers everything you need to know about localizing segmented buttons in Flutter.
Understanding SegmentedButton Localization Needs
Segmented buttons require localization for:
- Segment labels: Text on each option
- Icons with labels: Combined icon and text
- Tooltips: Additional context on hover/long-press
- Accessibility: Screen reader announcements
- RTL support: Proper segment ordering
- Selection state: Announcements for selected/unselected
Basic SegmentedButton with Localization
Start with a properly localized segmented button:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
enum ViewMode { list, grid, table }
class LocalizedSegmentedButton extends StatefulWidget {
const LocalizedSegmentedButton({super.key});
@override
State<LocalizedSegmentedButton> createState() =>
_LocalizedSegmentedButtonState();
}
class _LocalizedSegmentedButtonState extends State<LocalizedSegmentedButton> {
ViewMode _selectedView = ViewMode.list;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.viewModeLabel,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
SegmentedButton<ViewMode>(
segments: [
ButtonSegment(
value: ViewMode.list,
icon: const Icon(Icons.view_list),
label: Text(l10n.viewModeList),
tooltip: l10n.viewModeListTooltip,
),
ButtonSegment(
value: ViewMode.grid,
icon: const Icon(Icons.grid_view),
label: Text(l10n.viewModeGrid),
tooltip: l10n.viewModeGridTooltip,
),
ButtonSegment(
value: ViewMode.table,
icon: const Icon(Icons.table_rows),
label: Text(l10n.viewModeTable),
tooltip: l10n.viewModeTableTooltip,
),
],
selected: {_selectedView},
onSelectionChanged: (selection) {
setState(() => _selectedView = selection.first);
},
),
],
);
}
}
ARB file entries:
{
"viewModeLabel": "View mode",
"@viewModeLabel": {
"description": "Label for view mode selector"
},
"viewModeList": "List",
"@viewModeList": {
"description": "List view option"
},
"viewModeGrid": "Grid",
"@viewModeGrid": {
"description": "Grid view option"
},
"viewModeTable": "Table",
"@viewModeTable": {
"description": "Table view option"
},
"viewModeListTooltip": "Display items in a list",
"viewModeGridTooltip": "Display items in a grid",
"viewModeTableTooltip": "Display items in a table"
}
Multi-Select Segmented Button
Handle multiple selection with proper localization:
enum DayOfWeek { mon, tue, wed, thu, fri, sat, sun }
class LocalizedMultiSelectSegment extends StatefulWidget {
const LocalizedMultiSelectSegment({super.key});
@override
State<LocalizedMultiSelectSegment> createState() =>
_LocalizedMultiSelectSegmentState();
}
class _LocalizedMultiSelectSegmentState
extends State<LocalizedMultiSelectSegment> {
Set<DayOfWeek> _selectedDays = {DayOfWeek.mon, DayOfWeek.wed, DayOfWeek.fri};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.repeatDaysLabel,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
SegmentedButton<DayOfWeek>(
multiSelectionEnabled: true,
emptySelectionAllowed: true,
segments: [
ButtonSegment(
value: DayOfWeek.mon,
label: Text(_getShortDayName(DayOfWeek.mon, l10n)),
tooltip: _getFullDayName(DayOfWeek.mon, l10n),
),
ButtonSegment(
value: DayOfWeek.tue,
label: Text(_getShortDayName(DayOfWeek.tue, l10n)),
tooltip: _getFullDayName(DayOfWeek.tue, l10n),
),
ButtonSegment(
value: DayOfWeek.wed,
label: Text(_getShortDayName(DayOfWeek.wed, l10n)),
tooltip: _getFullDayName(DayOfWeek.wed, l10n),
),
ButtonSegment(
value: DayOfWeek.thu,
label: Text(_getShortDayName(DayOfWeek.thu, l10n)),
tooltip: _getFullDayName(DayOfWeek.thu, l10n),
),
ButtonSegment(
value: DayOfWeek.fri,
label: Text(_getShortDayName(DayOfWeek.fri, l10n)),
tooltip: _getFullDayName(DayOfWeek.fri, l10n),
),
ButtonSegment(
value: DayOfWeek.sat,
label: Text(_getShortDayName(DayOfWeek.sat, l10n)),
tooltip: _getFullDayName(DayOfWeek.sat, l10n),
),
ButtonSegment(
value: DayOfWeek.sun,
label: Text(_getShortDayName(DayOfWeek.sun, l10n)),
tooltip: _getFullDayName(DayOfWeek.sun, l10n),
),
],
selected: _selectedDays,
onSelectionChanged: (selection) {
setState(() => _selectedDays = selection);
},
),
const SizedBox(height: 8),
Text(
_getSelectionSummary(l10n),
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
String _getShortDayName(DayOfWeek day, AppLocalizations l10n) {
switch (day) {
case DayOfWeek.mon:
return l10n.dayMonShort;
case DayOfWeek.tue:
return l10n.dayTueShort;
case DayOfWeek.wed:
return l10n.dayWedShort;
case DayOfWeek.thu:
return l10n.dayThuShort;
case DayOfWeek.fri:
return l10n.dayFriShort;
case DayOfWeek.sat:
return l10n.daySatShort;
case DayOfWeek.sun:
return l10n.daySunShort;
}
}
String _getFullDayName(DayOfWeek day, AppLocalizations l10n) {
switch (day) {
case DayOfWeek.mon:
return l10n.dayMonday;
case DayOfWeek.tue:
return l10n.dayTuesday;
case DayOfWeek.wed:
return l10n.dayWednesday;
case DayOfWeek.thu:
return l10n.dayThursday;
case DayOfWeek.fri:
return l10n.dayFriday;
case DayOfWeek.sat:
return l10n.daySaturday;
case DayOfWeek.sun:
return l10n.daySunday;
}
}
String _getSelectionSummary(AppLocalizations l10n) {
if (_selectedDays.isEmpty) {
return l10n.noDaysSelected;
}
if (_selectedDays.length == 7) {
return l10n.everyDay;
}
final weekdays = {
DayOfWeek.mon,
DayOfWeek.tue,
DayOfWeek.wed,
DayOfWeek.thu,
DayOfWeek.fri,
};
final weekends = {DayOfWeek.sat, DayOfWeek.sun};
if (_selectedDays.containsAll(weekdays) &&
!_selectedDays.contains(DayOfWeek.sat) &&
!_selectedDays.contains(DayOfWeek.sun)) {
return l10n.weekdaysOnly;
}
if (_selectedDays.containsAll(weekends) && _selectedDays.length == 2) {
return l10n.weekendsOnly;
}
return l10n.daysSelected(_selectedDays.length);
}
}
ARB entries:
{
"repeatDaysLabel": "Repeat on",
"dayMonShort": "M",
"dayTueShort": "T",
"dayWedShort": "W",
"dayThuShort": "T",
"dayFriShort": "F",
"daySatShort": "S",
"daySunShort": "S",
"dayMonday": "Monday",
"dayTuesday": "Tuesday",
"dayWednesday": "Wednesday",
"dayThursday": "Thursday",
"dayFriday": "Friday",
"daySaturday": "Saturday",
"daySunday": "Sunday",
"noDaysSelected": "No days selected",
"everyDay": "Every day",
"weekdaysOnly": "Weekdays only",
"weekendsOnly": "Weekends only",
"daysSelected": "{count, plural, =1{1 day selected} other{{count} days selected}}",
"@daysSelected": {
"placeholders": {
"count": {"type": "int"}
}
}
}
Filter Segments with Dynamic Labels
Create filter controls with count badges:
enum FilterStatus { all, active, completed, archived }
class FilterSegmentedButton extends StatefulWidget {
final Map<FilterStatus, int> counts;
final FilterStatus selected;
final ValueChanged<FilterStatus> onChanged;
const FilterSegmentedButton({
super.key,
required this.counts,
required this.selected,
required this.onChanged,
});
@override
State<FilterSegmentedButton> createState() => _FilterSegmentedButtonState();
}
class _FilterSegmentedButtonState extends State<FilterSegmentedButton> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final numberFormat = NumberFormat.compact(locale: locale.toString());
return SegmentedButton<FilterStatus>(
segments: [
ButtonSegment(
value: FilterStatus.all,
label: Text(_buildLabel(
l10n.filterAll,
widget.counts[FilterStatus.all] ?? 0,
numberFormat,
)),
),
ButtonSegment(
value: FilterStatus.active,
icon: const Icon(Icons.circle, size: 12, color: Colors.green),
label: Text(_buildLabel(
l10n.filterActive,
widget.counts[FilterStatus.active] ?? 0,
numberFormat,
)),
),
ButtonSegment(
value: FilterStatus.completed,
icon: const Icon(Icons.check_circle, size: 12),
label: Text(_buildLabel(
l10n.filterCompleted,
widget.counts[FilterStatus.completed] ?? 0,
numberFormat,
)),
),
ButtonSegment(
value: FilterStatus.archived,
icon: const Icon(Icons.archive, size: 12),
label: Text(_buildLabel(
l10n.filterArchived,
widget.counts[FilterStatus.archived] ?? 0,
numberFormat,
)),
),
],
selected: {widget.selected},
onSelectionChanged: (selection) {
widget.onChanged(selection.first);
},
);
}
String _buildLabel(String text, int count, NumberFormat format) {
if (count == 0) return text;
return '$text (${format.format(count)})';
}
}
ARB entries:
{
"filterAll": "All",
"filterActive": "Active",
"filterCompleted": "Completed",
"filterArchived": "Archived"
}
Segmented Button with Icons Only
Handle icon-only segments with accessibility:
enum TextAlignment { left, center, right, justify }
class IconOnlySegmentedButton extends StatefulWidget {
const IconOnlySegmentedButton({super.key});
@override
State<IconOnlySegmentedButton> createState() =>
_IconOnlySegmentedButtonState();
}
class _IconOnlySegmentedButtonState extends State<IconOnlySegmentedButton> {
TextAlignment _alignment = TextAlignment.left;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isRTL = Directionality.of(context) == TextDirection.rtl;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.textAlignmentLabel,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
Semantics(
label: l10n.textAlignmentAccessibilityLabel,
child: SegmentedButton<TextAlignment>(
showSelectedIcon: false,
segments: [
ButtonSegment(
value: TextAlignment.left,
icon: Icon(isRTL ? Icons.format_align_right : Icons.format_align_left),
tooltip: l10n.alignLeft,
),
ButtonSegment(
value: TextAlignment.center,
icon: const Icon(Icons.format_align_center),
tooltip: l10n.alignCenter,
),
ButtonSegment(
value: TextAlignment.right,
icon: Icon(isRTL ? Icons.format_align_left : Icons.format_align_right),
tooltip: l10n.alignRight,
),
ButtonSegment(
value: TextAlignment.justify,
icon: const Icon(Icons.format_align_justify),
tooltip: l10n.alignJustify,
),
],
selected: {_alignment},
onSelectionChanged: (selection) {
setState(() => _alignment = selection.first);
_announceSelection(selection.first, l10n, context);
},
),
),
],
);
}
void _announceSelection(
TextAlignment alignment,
AppLocalizations l10n,
BuildContext context,
) {
String announcement;
switch (alignment) {
case TextAlignment.left:
announcement = l10n.alignLeftSelected;
break;
case TextAlignment.center:
announcement = l10n.alignCenterSelected;
break;
case TextAlignment.right:
announcement = l10n.alignRightSelected;
break;
case TextAlignment.justify:
announcement = l10n.alignJustifySelected;
break;
}
SemanticsService.announce(announcement, Directionality.of(context));
}
}
ARB entries:
{
"textAlignmentLabel": "Text alignment",
"textAlignmentAccessibilityLabel": "Text alignment options",
"alignLeft": "Align left",
"alignCenter": "Center",
"alignRight": "Align right",
"alignJustify": "Justify",
"alignLeftSelected": "Left alignment selected",
"alignCenterSelected": "Center alignment selected",
"alignRightSelected": "Right alignment selected",
"alignJustifySelected": "Justify alignment selected"
}
Responsive Segmented Button
Adapt segment labels for different screen sizes:
class ResponsiveSegmentedButton extends StatefulWidget {
const ResponsiveSegmentedButton({super.key});
@override
State<ResponsiveSegmentedButton> createState() =>
_ResponsiveSegmentedButtonState();
}
class _ResponsiveSegmentedButtonState extends State<ResponsiveSegmentedButton> {
String _selected = 'week';
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isCompact = MediaQuery.of(context).size.width < 400;
return SegmentedButton<String>(
segments: [
ButtonSegment(
value: 'day',
icon: isCompact ? const Icon(Icons.today) : null,
label: Text(isCompact ? l10n.periodDayShort : l10n.periodDay),
tooltip: l10n.periodDayTooltip,
),
ButtonSegment(
value: 'week',
icon: isCompact ? const Icon(Icons.view_week) : null,
label: Text(isCompact ? l10n.periodWeekShort : l10n.periodWeek),
tooltip: l10n.periodWeekTooltip,
),
ButtonSegment(
value: 'month',
icon: isCompact ? const Icon(Icons.calendar_month) : null,
label: Text(isCompact ? l10n.periodMonthShort : l10n.periodMonth),
tooltip: l10n.periodMonthTooltip,
),
ButtonSegment(
value: 'year',
icon: isCompact ? const Icon(Icons.calendar_today) : null,
label: Text(isCompact ? l10n.periodYearShort : l10n.periodYear),
tooltip: l10n.periodYearTooltip,
),
],
selected: {_selected},
onSelectionChanged: (selection) {
setState(() => _selected = selection.first);
},
);
}
}
ARB entries:
{
"periodDay": "Day",
"periodDayShort": "D",
"periodDayTooltip": "View by day",
"periodWeek": "Week",
"periodWeekShort": "W",
"periodWeekTooltip": "View by week",
"periodMonth": "Month",
"periodMonthShort": "M",
"periodMonthTooltip": "View by month",
"periodYear": "Year",
"periodYearShort": "Y",
"periodYearTooltip": "View by year"
}
Segmented Button with Disabled States
Handle disabled segments with proper messaging:
class DisabledSegmentedButton extends StatefulWidget {
final bool isPremiumUser;
const DisabledSegmentedButton({
super.key,
required this.isPremiumUser,
});
@override
State<DisabledSegmentedButton> createState() =>
_DisabledSegmentedButtonState();
}
class _DisabledSegmentedButtonState extends State<DisabledSegmentedButton> {
String _exportFormat = 'pdf';
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.exportFormatLabel,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
SegmentedButton<String>(
segments: [
ButtonSegment(
value: 'pdf',
icon: const Icon(Icons.picture_as_pdf),
label: Text(l10n.exportPdf),
),
ButtonSegment(
value: 'csv',
icon: const Icon(Icons.table_chart),
label: Text(l10n.exportCsv),
),
ButtonSegment(
value: 'excel',
icon: const Icon(Icons.grid_on),
label: Text(l10n.exportExcel),
enabled: widget.isPremiumUser,
),
ButtonSegment(
value: 'json',
icon: const Icon(Icons.code),
label: Text(l10n.exportJson),
enabled: widget.isPremiumUser,
),
],
selected: {_exportFormat},
onSelectionChanged: (selection) {
setState(() => _exportFormat = selection.first);
},
),
if (!widget.isPremiumUser) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: Theme.of(context).hintColor,
),
const SizedBox(width: 4),
Flexible(
child: Text(
l10n.premiumFormatsMessage,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
],
],
);
}
}
ARB entries:
{
"exportFormatLabel": "Export format",
"exportPdf": "PDF",
"exportCsv": "CSV",
"exportExcel": "Excel",
"exportJson": "JSON",
"premiumFormatsMessage": "Excel and JSON formats require a premium subscription"
}
Testing Segmented Button Localization
Write comprehensive tests for segmented button behavior:
void main() {
group('LocalizedSegmentedButton Tests', () {
testWidgets('displays localized labels', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
locale: const Locale('en'),
home: const Scaffold(
body: LocalizedSegmentedButton(),
),
),
);
expect(find.text('List'), findsOneWidget);
expect(find.text('Grid'), findsOneWidget);
expect(find.text('Table'), findsOneWidget);
});
testWidgets('shows German labels', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
locale: const Locale('de'),
home: const Scaffold(
body: LocalizedSegmentedButton(),
),
),
);
expect(find.text('Liste'), findsOneWidget);
expect(find.text('Raster'), findsOneWidget);
expect(find.text('Tabelle'), findsOneWidget);
});
testWidgets('handles RTL layout correctly', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
locale: const Locale('ar'),
home: const Directionality(
textDirection: TextDirection.rtl,
child: Scaffold(
body: IconOnlySegmentedButton(),
),
),
),
);
// Verify RTL icons are mirrored
expect(find.byIcon(Icons.format_align_right), findsOneWidget);
});
testWidgets('announces selection to screen readers', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
locale: const Locale('en'),
home: const Scaffold(
body: LocalizedSegmentedButton(),
),
),
);
// Tap on Grid segment
await tester.tap(find.text('Grid'));
await tester.pumpAndSettle();
// Verify selection changed
final segmentedButton = tester.widget<SegmentedButton<ViewMode>>(
find.byType(SegmentedButton<ViewMode>),
);
expect(segmentedButton.selected, {ViewMode.grid});
});
});
}
Best Practices Summary
- Localize all labels: Segment text, tooltips, accessibility labels
- Handle RTL: Mirror directional icons and layouts
- Use short labels: Abbreviate on small screens
- Include tooltips: Provide context for icon-only segments
- Announce changes: Screen reader accessibility
- Show disabled reasons: Explain why options are unavailable
- Format counts: Use locale-appropriate number formatting
Conclusion
Localizing segmented buttons in Flutter requires attention to labels, tooltips, accessibility, and RTL support. By implementing proper localized text, responsive labels, and accessible announcements, you ensure users worldwide can effectively interact with your selection controls.