← Back to Blog

Flutter ListWheelScrollView Localization: Wheel-Style Pickers for Multilingual Apps

flutterlistwheelscrollviewpickerscrollinglocalizationrtl

Flutter ListWheelScrollView Localization: Wheel-Style Pickers for Multilingual Apps

ListWheelScrollView is a Flutter widget that displays children on a wheel, creating a 3D barrel-like scrolling effect commonly seen in iOS date pickers. In multilingual applications, ListWheelScrollView is essential for building locale-aware pickers for values like months, cities, and units, displaying translated options in a wheel format, adapting item sizing for different script lengths, and providing accessible wheel selection in the active language.

Understanding ListWheelScrollView in Localization Context

ListWheelScrollView renders children along a cylindrical wheel with perspective effects, where the centered item is the selected value. For multilingual apps, this enables:

  • Translated picker options displayed on a scrollable wheel
  • Locale-specific value lists (month names, day names, units)
  • Adaptive item sizing for translations of varying length
  • Accessible selection announcements in the active language

Why ListWheelScrollView Matters for Multilingual Apps

ListWheelScrollView provides:

  • Picker UX: Native wheel picker feel for translated option selection
  • Perspective effect: 3D cylinder rendering that works with any text direction
  • Fixed item extent: Consistent item heights across all translations
  • Snap behavior: Selected item snaps to center for clear translated value selection

Basic ListWheelScrollView Implementation

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedListWheelExample extends StatefulWidget {
  const LocalizedListWheelExample({super.key});

  @override
  State<LocalizedListWheelExample> createState() =>
      _LocalizedListWheelExampleState();
}

class _LocalizedListWheelExampleState extends State<LocalizedListWheelExample> {
  int _selectedIndex = 0;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    final months = [
      l10n.january, l10n.february, l10n.march,
      l10n.april, l10n.may, l10n.june,
      l10n.july, l10n.august, l10n.september,
      l10n.october, l10n.november, l10n.december,
    ];

    return Scaffold(
      appBar: AppBar(title: Text(l10n.selectMonthTitle)),
      body: Column(
        children: [
          Expanded(
            child: ListWheelScrollView(
              itemExtent: 50,
              diameterRatio: 1.5,
              useMagnifier: true,
              magnification: 1.2,
              onSelectedItemChanged: (index) {
                setState(() => _selectedIndex = index);
              },
              children: months.map((month) {
                return Center(
                  child: Text(
                    month,
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                );
              }).toList(),
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(16),
            child: Text(
              l10n.selectedMonthLabel(months[_selectedIndex]),
              style: Theme.of(context).textTheme.titleLarge,
            ),
          ),
        ],
      ),
    );
  }
}

Advanced ListWheelScrollView Patterns for Localization

Multi-Wheel Date Picker

Multiple synchronized wheels for day, month, and year selection with localized labels.

class LocalizedMultiWheelPicker extends StatefulWidget {
  const LocalizedMultiWheelPicker({super.key});

  @override
  State<LocalizedMultiWheelPicker> createState() =>
      _LocalizedMultiWheelPickerState();
}

class _LocalizedMultiWheelPickerState extends State<LocalizedMultiWheelPicker> {
  int _selectedDay = 0;
  int _selectedMonth = 0;
  int _selectedYear = 0;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    final months = [
      l10n.january, l10n.february, l10n.march,
      l10n.april, l10n.may, l10n.june,
      l10n.july, l10n.august, l10n.september,
      l10n.october, l10n.november, l10n.december,
    ];

    return Scaffold(
      appBar: AppBar(title: Text(l10n.selectDateTitle)),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              children: [
                Expanded(
                  child: Column(
                    children: [
                      Text(l10n.dayLabel,
                          style: Theme.of(context).textTheme.labelMedium),
                      SizedBox(
                        height: 200,
                        child: ListWheelScrollView.useDelegate(
                          itemExtent: 40,
                          onSelectedItemChanged: (index) {
                            setState(() => _selectedDay = index);
                          },
                          childDelegate: ListWheelChildBuilderDelegate(
                            builder: (context, index) {
                              if (index < 0 || index > 30) return null;
                              return Center(
                                child: Text('${index + 1}'),
                              );
                            },
                            childCount: 31,
                          ),
                        ),
                      ),
                    ],
                  ),
                ),
                Expanded(
                  flex: 2,
                  child: Column(
                    children: [
                      Text(l10n.monthLabel,
                          style: Theme.of(context).textTheme.labelMedium),
                      SizedBox(
                        height: 200,
                        child: ListWheelScrollView(
                          itemExtent: 40,
                          onSelectedItemChanged: (index) {
                            setState(() => _selectedMonth = index);
                          },
                          children: months.map((month) {
                            return Center(child: Text(month));
                          }).toList(),
                        ),
                      ),
                    ],
                  ),
                ),
                Expanded(
                  child: Column(
                    children: [
                      Text(l10n.yearLabel,
                          style: Theme.of(context).textTheme.labelMedium),
                      SizedBox(
                        height: 200,
                        child: ListWheelScrollView.useDelegate(
                          itemExtent: 40,
                          onSelectedItemChanged: (index) {
                            setState(() => _selectedYear = index);
                          },
                          childDelegate: ListWheelChildBuilderDelegate(
                            builder: (context, index) {
                              if (index < 0 || index > 100) return null;
                              return Center(
                                child: Text('${2024 + index}'),
                              );
                            },
                            childCount: 101,
                          ),
                        ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(16),
            child: FilledButton(
              onPressed: () {},
              child: Text(l10n.confirmDateButton),
            ),
          ),
        ],
      ),
    );
  }
}

Unit Picker with Localized Labels

A wheel picker for selecting measurement units with translated unit names.

class LocalizedUnitPicker extends StatefulWidget {
  const LocalizedUnitPicker({super.key});

  @override
  State<LocalizedUnitPicker> createState() => _LocalizedUnitPickerState();
}

class _LocalizedUnitPickerState extends State<LocalizedUnitPicker> {
  int _selectedUnit = 0;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    final units = [
      (l10n.kilogramsUnit, 'kg'),
      (l10n.gramsUnit, 'g'),
      (l10n.poundsUnit, 'lb'),
      (l10n.ouncesUnit, 'oz'),
      (l10n.litersUnit, 'L'),
      (l10n.millilitersUnit, 'mL'),
    ];

    return Card(
      margin: const EdgeInsets.all(16),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(
              l10n.selectUnitTitle,
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            SizedBox(
              height: 180,
              child: ListWheelScrollView(
                itemExtent: 44,
                diameterRatio: 1.2,
                useMagnifier: true,
                magnification: 1.3,
                onSelectedItemChanged: (index) {
                  setState(() => _selectedUnit = index);
                },
                children: units.map((unit) {
                  return Center(
                    child: Text(
                      '${unit.$1} (${unit.$2})',
                      style: Theme.of(context).textTheme.bodyLarge,
                    ),
                  );
                }).toList(),
              ),
            ),
            const Divider(),
            Text(
              l10n.selectedUnitLabel(units[_selectedUnit].$1),
              style: Theme.of(context).textTheme.bodyMedium,
            ),
          ],
        ),
      ),
    );
  }
}

Country/City Picker

Wheel picker for locale-specific city or country selection.

class LocalizedCityPicker extends StatefulWidget {
  const LocalizedCityPicker({super.key});

  @override
  State<LocalizedCityPicker> createState() => _LocalizedCityPickerState();
}

class _LocalizedCityPickerState extends State<LocalizedCityPicker> {
  int _selectedCity = 0;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    final cities = [
      l10n.cityNewYork,
      l10n.cityLondon,
      l10n.cityTokyo,
      l10n.cityParis,
      l10n.cityDubai,
      l10n.cityBerlin,
      l10n.citySydney,
    ];

    return Column(
      children: [
        Text(
          l10n.selectCityTitle,
          style: Theme.of(context).textTheme.titleMedium,
        ),
        const SizedBox(height: 8),
        SizedBox(
          height: 200,
          child: ListWheelScrollView(
            itemExtent: 48,
            useMagnifier: true,
            magnification: 1.2,
            onSelectedItemChanged: (index) {
              setState(() => _selectedCity = index);
            },
            children: cities.map((city) {
              return Center(
                child: Text(
                  city,
                  style: Theme.of(context).textTheme.titleSmall,
                ),
              );
            }).toList(),
          ),
        ),
      ],
    );
  }
}

RTL Support and Bidirectional Layouts

ListWheelScrollView content automatically adapts to RTL. Text within wheel items aligns correctly, and surrounding labels follow the active directionality.

class BidirectionalListWheel extends StatelessWidget {
  const BidirectionalListWheel({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Padding(
      padding: const EdgeInsetsDirectional.all(16),
      child: Column(
        children: [
          Text(
            l10n.selectValueTitle,
            style: Theme.of(context).textTheme.titleMedium,
            textAlign: TextAlign.start,
          ),
          SizedBox(
            height: 200,
            child: ListWheelScrollView(
              itemExtent: 44,
              children: List.generate(10, (index) {
                return Center(
                  child: Text(
                    '${l10n.optionLabel} ${index + 1}',
                    style: Theme.of(context).textTheme.bodyLarge,
                  ),
                );
              }),
            ),
          ),
        ],
      ),
    );
  }
}

Testing ListWheelScrollView 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 LocalizedListWheelExample(),
    );
  }

  testWidgets('ListWheelScrollView renders localized items', (tester) async {
    await tester.pumpWidget(buildTestWidget());
    await tester.pumpAndSettle();
    expect(find.byType(ListWheelScrollView), findsOneWidget);
  });

  testWidgets('ListWheelScrollView works in RTL', (tester) async {
    await tester.pumpWidget(buildTestWidget(locale: const Locale('ar')));
    await tester.pumpAndSettle();
    expect(tester.takeException(), isNull);
  });
}

Best Practices

  1. Use useMagnifier: true to enlarge the selected translated item, making it clearly readable regardless of script size.

  2. Set itemExtent to accommodate the tallest translation -- test with verbose languages to ensure no clipping occurs.

  3. Use ListWheelChildBuilderDelegate for large value ranges (years, numbers) to lazily build only visible items.

  4. Provide a translated label above and below the wheel showing the current selection for accessibility and clarity.

  5. Localize month and day names using ARB strings rather than hardcoded English names for wheel pickers.

  6. Test wheel scrolling in RTL to verify text alignment and selection feedback display correctly.

Conclusion

ListWheelScrollView provides a wheel-style picker experience in Flutter, ideal for selecting values from localized lists like months, units, and cities. For multilingual apps, it handles translated options of varying lengths with consistent item extents, magnified selection for readability, and lazy building for large value ranges. By combining wheel pickers with translated labels, multi-wheel layouts, and locale-specific value lists, you can build picker experiences that feel native and intuitive in every supported language.

Further Reading