Flutter CupertinoPicker Localization: iOS-Style Scrollable Selection Widgets
CupertinoPicker and related iOS-style widgets provide native-feeling selection interfaces for iOS users. Localizing these pickers requires careful attention to date formats, time displays, and scrollable list items across different locales. This guide covers everything you need to know about localizing Cupertino pickers in Flutter.
Understanding Cupertino Picker Localization
Cupertino pickers require localization for:
- CupertinoPicker: Generic scrollable selection lists
- CupertinoDatePicker: Date and time selection with locale-aware formats
- CupertinoTimerPicker: Duration selection with localized labels
- Scrollable lists: Item labels in user's language
- Accessibility: VoiceOver announcements for iOS users
Basic CupertinoPicker with Localized Items
Start with a localized item picker:
import 'package:flutter/cupertino.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedCupertinoPicker extends StatefulWidget {
final ValueChanged<int> onItemSelected;
const LocalizedCupertinoPicker({
super.key,
required this.onItemSelected,
});
@override
State<LocalizedCupertinoPicker> createState() =>
_LocalizedCupertinoPickerState();
}
class _LocalizedCupertinoPickerState extends State<LocalizedCupertinoPicker> {
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
// Localized category items
final categories = [
l10n.categoryAll,
l10n.categoryElectronics,
l10n.categoryClothing,
l10n.categoryBooks,
l10n.categoryHome,
l10n.categorySports,
];
return Column(
children: [
Text(
l10n.selectCategoryLabel,
style: CupertinoTheme.of(context).textTheme.navTitleTextStyle,
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: CupertinoPicker(
itemExtent: 40,
scrollController: FixedExtentScrollController(
initialItem: _selectedIndex,
),
onSelectedItemChanged: (index) {
setState(() => _selectedIndex = index);
widget.onItemSelected(index);
},
children: categories
.map((category) => Center(
child: Text(
category,
style: const TextStyle(fontSize: 18),
),
))
.toList(),
),
),
const SizedBox(height: 16),
Text(
l10n.selectedCategoryMessage(categories[_selectedIndex]),
style: CupertinoTheme.of(context).textTheme.textStyle,
),
],
);
}
}
CupertinoDatePicker with Locale Support
The CupertinoDatePicker automatically adapts to the device locale:
import 'package:flutter/cupertino.dart';
import 'package:intl/intl.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedCupertinoDatePicker extends StatefulWidget {
final DateTime? initialDate;
final DateTime? minimumDate;
final DateTime? maximumDate;
final ValueChanged<DateTime> onDateSelected;
const LocalizedCupertinoDatePicker({
super.key,
this.initialDate,
this.minimumDate,
this.maximumDate,
required this.onDateSelected,
});
@override
State<LocalizedCupertinoDatePicker> createState() =>
_LocalizedCupertinoDatePickerState();
}
class _LocalizedCupertinoDatePickerState
extends State<LocalizedCupertinoDatePicker> {
late DateTime _selectedDate;
@override
void initState() {
super.initState();
_selectedDate = widget.initialDate ?? DateTime.now();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final dateFormat = DateFormat.yMMMMd(locale.toString());
return Column(
children: [
Text(
l10n.selectDateLabel,
style: CupertinoTheme.of(context).textTheme.navTitleTextStyle,
),
const SizedBox(height: 8),
Text(
l10n.selectedDateMessage(dateFormat.format(_selectedDate)),
style: CupertinoTheme.of(context).textTheme.textStyle.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: CupertinoDatePicker(
mode: CupertinoDatePickerMode.date,
initialDateTime: _selectedDate,
minimumDate: widget.minimumDate,
maximumDate: widget.maximumDate,
onDateTimeChanged: (date) {
setState(() => _selectedDate = date);
widget.onDateSelected(date);
},
),
),
],
);
}
}
Localized Time Picker with AM/PM Support
Handle 12-hour and 24-hour formats based on locale:
import 'package:flutter/cupertino.dart';
import 'package:intl/intl.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedCupertinoTimePicker extends StatefulWidget {
final DateTime? initialTime;
final ValueChanged<DateTime> onTimeSelected;
const LocalizedCupertinoTimePicker({
super.key,
this.initialTime,
required this.onTimeSelected,
});
@override
State<LocalizedCupertinoTimePicker> createState() =>
_LocalizedCupertinoTimePickerState();
}
class _LocalizedCupertinoTimePickerState
extends State<LocalizedCupertinoTimePicker> {
late DateTime _selectedTime;
@override
void initState() {
super.initState();
_selectedTime = widget.initialTime ?? DateTime.now();
}
bool _uses24HourFormat(Locale locale) {
// Most European countries use 24-hour format
final twentyFourHourLocales = [
'de', 'fr', 'es', 'it', 'pt', 'nl', 'pl', 'ru',
'ja', 'ko', 'zh', 'sv', 'no', 'da', 'fi',
];
return twentyFourHourLocales.contains(locale.languageCode);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final use24Hour = _uses24HourFormat(locale);
final timeFormat = use24Hour
? DateFormat.Hm(locale.toString())
: DateFormat.jm(locale.toString());
return Column(
children: [
Text(
l10n.selectTimeLabel,
style: CupertinoTheme.of(context).textTheme.navTitleTextStyle,
),
const SizedBox(height: 8),
Text(
l10n.selectedTimeMessage(timeFormat.format(_selectedTime)),
style: CupertinoTheme.of(context).textTheme.textStyle.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: CupertinoDatePicker(
mode: CupertinoDatePickerMode.time,
initialDateTime: _selectedTime,
use24hFormat: use24Hour,
onDateTimeChanged: (time) {
setState(() => _selectedTime = time);
widget.onTimeSelected(time);
},
),
),
],
);
}
}
CupertinoTimerPicker with Localized Labels
Create a duration picker with localized hour/minute/second labels:
import 'package:flutter/cupertino.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedCupertinoTimerPicker extends StatefulWidget {
final Duration initialDuration;
final ValueChanged<Duration> onDurationChanged;
const LocalizedCupertinoTimerPicker({
super.key,
this.initialDuration = Duration.zero,
required this.onDurationChanged,
});
@override
State<LocalizedCupertinoTimerPicker> createState() =>
_LocalizedCupertinoTimerPickerState();
}
class _LocalizedCupertinoTimerPickerState
extends State<LocalizedCupertinoTimerPicker> {
late Duration _selectedDuration;
@override
void initState() {
super.initState();
_selectedDuration = widget.initialDuration;
}
String _formatDuration(Duration duration, AppLocalizations l10n) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
final parts = <String>[];
if (hours > 0) {
parts.add(l10n.hoursCount(hours));
}
if (minutes > 0) {
parts.add(l10n.minutesCount(minutes));
}
if (seconds > 0 || parts.isEmpty) {
parts.add(l10n.secondsCount(seconds));
}
return parts.join(' ');
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
Text(
l10n.setTimerLabel,
style: CupertinoTheme.of(context).textTheme.navTitleTextStyle,
),
const SizedBox(height: 8),
Text(
l10n.timerDurationMessage(_formatDuration(_selectedDuration, l10n)),
style: CupertinoTheme.of(context).textTheme.textStyle.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: CupertinoTimerPicker(
mode: CupertinoTimerPickerMode.hms,
initialTimerDuration: _selectedDuration,
onTimerDurationChanged: (duration) {
setState(() => _selectedDuration = duration);
widget.onDurationChanged(duration);
},
),
),
],
);
}
}
Multi-Column Cupertino Picker
Create a localized multi-column picker for complex selections:
import 'package:flutter/cupertino.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedSizePicker extends StatefulWidget {
final ValueChanged<({String size, String color})> onSelectionChanged;
const LocalizedSizePicker({
super.key,
required this.onSelectionChanged,
});
@override
State<LocalizedSizePicker> createState() => _LocalizedSizePickerState();
}
class _LocalizedSizePickerState extends State<LocalizedSizePicker> {
int _selectedSizeIndex = 0;
int _selectedColorIndex = 0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final sizes = [
l10n.sizeExtraSmall,
l10n.sizeSmall,
l10n.sizeMedium,
l10n.sizeLarge,
l10n.sizeExtraLarge,
];
final colors = [
l10n.colorRed,
l10n.colorBlue,
l10n.colorGreen,
l10n.colorBlack,
l10n.colorWhite,
];
return Column(
children: [
Text(
l10n.selectSizeAndColorLabel,
style: CupertinoTheme.of(context).textTheme.navTitleTextStyle,
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: Row(
children: [
// Size column
Expanded(
child: Column(
children: [
Text(
l10n.sizeLabel,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 8),
Expanded(
child: CupertinoPicker(
itemExtent: 36,
onSelectedItemChanged: (index) {
setState(() => _selectedSizeIndex = index);
_notifyChange(sizes, colors);
},
children: sizes
.map((size) => Center(child: Text(size)))
.toList(),
),
),
],
),
),
// Color column
Expanded(
child: Column(
children: [
Text(
l10n.colorLabel,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 8),
Expanded(
child: CupertinoPicker(
itemExtent: 36,
onSelectedItemChanged: (index) {
setState(() => _selectedColorIndex = index);
_notifyChange(sizes, colors);
},
children: colors
.map((color) => Center(child: Text(color)))
.toList(),
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
Text(
l10n.selectedSizeAndColor(
sizes[_selectedSizeIndex],
colors[_selectedColorIndex],
),
),
],
);
}
void _notifyChange(List<String> sizes, List<String> colors) {
widget.onSelectionChanged((
size: sizes[_selectedSizeIndex],
color: colors[_selectedColorIndex],
));
}
}
Cupertino Picker in Modal Sheet
Present pickers in a localized modal bottom sheet:
import 'package:flutter/cupertino.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class CupertinoPickerSheet {
static Future<int?> showPicker({
required BuildContext context,
required String title,
required List<String> items,
int initialIndex = 0,
}) async {
final l10n = AppLocalizations.of(context)!;
int selectedIndex = initialIndex;
return showCupertinoModalPopup<int>(
context: context,
builder: (context) => Container(
height: 300,
decoration: BoxDecoration(
color: CupertinoTheme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
children: [
// Header with cancel/done buttons
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: CupertinoColors.separator.resolveFrom(context),
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancelButton),
),
Text(
title,
style: CupertinoTheme.of(context)
.textTheme
.navTitleTextStyle,
),
CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => Navigator.pop(context, selectedIndex),
child: Text(
l10n.doneButton,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
),
// Picker
Expanded(
child: CupertinoPicker(
itemExtent: 40,
scrollController: FixedExtentScrollController(
initialItem: initialIndex,
),
onSelectedItemChanged: (index) => selectedIndex = index,
children: items
.map((item) => Center(
child: Text(
item,
style: const TextStyle(fontSize: 18),
),
))
.toList(),
),
),
],
),
),
);
}
}
// Usage example
class PickerExample extends StatelessWidget {
const PickerExample({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return CupertinoButton(
child: Text(l10n.selectCountry),
onPressed: () async {
final countries = [
l10n.countryUSA,
l10n.countryUK,
l10n.countryGermany,
l10n.countryFrance,
l10n.countryJapan,
];
final result = await CupertinoPickerSheet.showPicker(
context: context,
title: l10n.selectCountryTitle,
items: countries,
);
if (result != null) {
// Handle selection
debugPrint('Selected: ${countries[result]}');
}
},
);
}
}
Accessibility for Cupertino Pickers
Ensure pickers work well with VoiceOver:
import 'package:flutter/cupertino.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class AccessibleCupertinoPicker extends StatefulWidget {
final List<String> items;
final int initialIndex;
final ValueChanged<int> onChanged;
const AccessibleCupertinoPicker({
super.key,
required this.items,
this.initialIndex = 0,
required this.onChanged,
});
@override
State<AccessibleCupertinoPicker> createState() =>
_AccessibleCupertinoPickerState();
}
class _AccessibleCupertinoPickerState extends State<AccessibleCupertinoPicker> {
late int _selectedIndex;
@override
void initState() {
super.initState();
_selectedIndex = widget.initialIndex;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
label: l10n.pickerAccessibilityLabel,
hint: l10n.pickerAccessibilityHint,
value: l10n.pickerCurrentValue(
_selectedIndex + 1,
widget.items.length,
widget.items[_selectedIndex],
),
child: CupertinoPicker(
itemExtent: 40,
scrollController: FixedExtentScrollController(
initialItem: _selectedIndex,
),
onSelectedItemChanged: (index) {
setState(() => _selectedIndex = index);
widget.onChanged(index);
// Announce selection change for screen readers
SemanticsService.announce(
l10n.pickerSelectionAnnouncement(widget.items[index]),
TextDirection.ltr,
);
},
children: widget.items.asMap().entries.map((entry) {
return Semantics(
label: l10n.pickerItemLabel(entry.key + 1, widget.items.length),
child: Center(
child: Text(
entry.value,
style: const TextStyle(fontSize: 18),
),
),
);
}).toList(),
),
);
}
}
ARB Translations for Cupertino Pickers
Add these entries to your ARB files:
{
"selectCategoryLabel": "Select Category",
"@selectCategoryLabel": {
"description": "Label for category picker"
},
"selectedCategoryMessage": "Selected: {category}",
"@selectedCategoryMessage": {
"placeholders": {
"category": {"type": "String"}
}
},
"categoryAll": "All Categories",
"categoryElectronics": "Electronics",
"categoryClothing": "Clothing",
"categoryBooks": "Books",
"categoryHome": "Home & Garden",
"categorySports": "Sports & Outdoors",
"selectDateLabel": "Select Date",
"selectedDateMessage": "Selected: {date}",
"@selectedDateMessage": {
"placeholders": {
"date": {"type": "String"}
}
},
"selectTimeLabel": "Select Time",
"selectedTimeMessage": "Selected: {time}",
"@selectedTimeMessage": {
"placeholders": {
"time": {"type": "String"}
}
},
"setTimerLabel": "Set Timer",
"timerDurationMessage": "Duration: {duration}",
"@timerDurationMessage": {
"placeholders": {
"duration": {"type": "String"}
}
},
"hoursCount": "{count, plural, =0{0 hours} =1{1 hour} other{{count} hours}}",
"@hoursCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"minutesCount": "{count, plural, =0{0 minutes} =1{1 minute} other{{count} minutes}}",
"@minutesCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"secondsCount": "{count, plural, =0{0 seconds} =1{1 second} other{{count} seconds}}",
"@secondsCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"selectSizeAndColorLabel": "Select Size and Color",
"sizeLabel": "Size",
"colorLabel": "Color",
"sizeExtraSmall": "XS",
"sizeSmall": "S",
"sizeMedium": "M",
"sizeLarge": "L",
"sizeExtraLarge": "XL",
"colorRed": "Red",
"colorBlue": "Blue",
"colorGreen": "Green",
"colorBlack": "Black",
"colorWhite": "White",
"selectedSizeAndColor": "Selected: {size}, {color}",
"@selectedSizeAndColor": {
"placeholders": {
"size": {"type": "String"},
"color": {"type": "String"}
}
},
"cancelButton": "Cancel",
"doneButton": "Done",
"selectCountry": "Select Country",
"selectCountryTitle": "Country",
"countryUSA": "United States",
"countryUK": "United Kingdom",
"countryGermany": "Germany",
"countryFrance": "France",
"countryJapan": "Japan",
"pickerAccessibilityLabel": "Selection picker",
"pickerAccessibilityHint": "Swipe up or down to change selection",
"pickerCurrentValue": "Item {current} of {total}, {value}",
"@pickerCurrentValue": {
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"},
"value": {"type": "String"}
}
},
"pickerSelectionAnnouncement": "Selected {value}",
"@pickerSelectionAnnouncement": {
"placeholders": {
"value": {"type": "String"}
}
},
"pickerItemLabel": "Item {position} of {total}",
"@pickerItemLabel": {
"placeholders": {
"position": {"type": "int"},
"total": {"type": "int"}
}
}
}
Testing Cupertino Picker Localization
Write tests for your localized pickers:
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() {
group('LocalizedCupertinoPicker', () {
testWidgets('displays localized categories in English', (tester) async {
await tester.pumpWidget(
CupertinoApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: LocalizedCupertinoPicker(
onItemSelected: (_) {},
),
),
);
expect(find.text('Select Category'), findsOneWidget);
expect(find.text('All Categories'), findsOneWidget);
expect(find.text('Electronics'), findsOneWidget);
});
testWidgets('displays localized categories in Spanish', (tester) async {
await tester.pumpWidget(
CupertinoApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('es'),
home: LocalizedCupertinoPicker(
onItemSelected: (_) {},
),
),
);
expect(find.text('Seleccionar Categoría'), findsOneWidget);
expect(find.text('Todas las Categorías'), findsOneWidget);
expect(find.text('Electrónica'), findsOneWidget);
});
testWidgets('uses 24-hour format for German locale', (tester) async {
await tester.pumpWidget(
CupertinoApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('de'),
home: LocalizedCupertinoTimePicker(
initialTime: DateTime(2024, 1, 1, 14, 30),
onTimeSelected: (_) {},
),
),
);
// Should display 14:30 format
expect(find.textContaining('14:30'), findsOneWidget);
});
testWidgets('uses 12-hour format for US locale', (tester) async {
await tester.pumpWidget(
CupertinoApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en', 'US'),
home: LocalizedCupertinoTimePicker(
initialTime: DateTime(2024, 1, 1, 14, 30),
onTimeSelected: (_) {},
),
),
);
// Should display 2:30 PM format
expect(find.textContaining('2:30 PM'), findsOneWidget);
});
});
}
Summary
Localizing Cupertino pickers in Flutter requires:
- Locale-aware date/time formats using the intl package
- Localized item labels for all picker options
- Proper 12/24-hour detection based on user's locale
- Pluralized duration strings for timer pickers
- Accessibility labels for VoiceOver support
- Modal sheet translations for cancel/done buttons
- Comprehensive testing across different locales
Cupertino pickers provide a native iOS experience, and proper localization ensures your app feels natural to users regardless of their language or region.