← Back to Blog

Flutter CupertinoPicker Localization: iOS-Style Scrollable Selection Widgets

fluttercupertinoiospickerlocalizationaccessibility

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:

  1. Locale-aware date/time formats using the intl package
  2. Localized item labels for all picker options
  3. Proper 12/24-hour detection based on user's locale
  4. Pluralized duration strings for timer pickers
  5. Accessibility labels for VoiceOver support
  6. Modal sheet translations for cancel/done buttons
  7. 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.