← Back to Blog

Flutter RadioListTile Localization: Single-Select Options for Multilingual Apps

flutterradiolisttileradiomateriallocalizationrtl

Flutter RadioListTile Localization: Single-Select Options for Multilingual Apps

RadioListTile is a Flutter Material Design widget that combines a ListTile with a Radio button, creating a tappable row for selecting one option from a group. In multilingual applications, RadioListTile must handle translated option labels that vary in length, adapt radio button positioning for RTL layouts, group related options with localized headers, and provide accessible announcements that describe the selected choice in the active language.

Understanding RadioListTile in Localization Context

RadioListTile renders a list tile with a leading (or trailing) radio button, used for mutually exclusive selections like language preferences, sort orders, and theme choices. For multilingual apps, this enables:

  • Translated option labels and descriptions that wrap within the available space
  • Automatic RTL layout reversal where radio buttons reposition correctly
  • Grouped radio options with localized group headers and descriptions
  • Accessible selection announcements in the active language

Why RadioListTile Matters for Multilingual Apps

RadioListTile provides:

  • Single-select patterns: Users choose one translated option from a group
  • RTL positioning: Radio button automatically moves to the correct side in RTL locales
  • Language selection: The most natural widget for locale pickers within settings
  • Grouped choices: Sort order, theme, and display preferences with localized labels

Basic RadioListTile Implementation

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

enum ThemeMode { system, light, dark }

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

  @override
  State<LocalizedRadioListTileExample> createState() =>
      _LocalizedRadioListTileExampleState();
}

class _LocalizedRadioListTileExampleState
    extends State<LocalizedRadioListTileExample> {
  ThemeMode _selectedTheme = ThemeMode.system;

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

    return Scaffold(
      appBar: AppBar(title: Text(l10n.themeSettingsTitle)),
      body: ListView(
        children: [
          Padding(
            padding: const EdgeInsetsDirectional.fromSTEB(16, 16, 16, 8),
            child: Text(
              l10n.chooseThemeHeading,
              style: Theme.of(context).textTheme.titleMedium,
            ),
          ),
          RadioListTile<ThemeMode>(
            title: Text(l10n.systemThemeLabel),
            subtitle: Text(l10n.systemThemeDescription),
            value: ThemeMode.system,
            groupValue: _selectedTheme,
            onChanged: (v) => setState(() => _selectedTheme = v!),
          ),
          RadioListTile<ThemeMode>(
            title: Text(l10n.lightThemeLabel),
            subtitle: Text(l10n.lightThemeDescription),
            value: ThemeMode.light,
            groupValue: _selectedTheme,
            onChanged: (v) => setState(() => _selectedTheme = v!),
          ),
          RadioListTile<ThemeMode>(
            title: Text(l10n.darkThemeLabel),
            subtitle: Text(l10n.darkThemeDescription),
            value: ThemeMode.dark,
            groupValue: _selectedTheme,
            onChanged: (v) => setState(() => _selectedTheme = v!),
          ),
        ],
      ),
    );
  }
}

Advanced RadioListTile Patterns for Localization

Language Selector with Native Names

The most common RadioListTile localization use case is a language picker showing each language in its native script alongside a translated name.

class LanguageSelector extends StatefulWidget {
  final Locale currentLocale;
  final ValueChanged<Locale> onLocaleChanged;

  const LanguageSelector({
    super.key,
    required this.currentLocale,
    required this.onLocaleChanged,
  });

  @override
  State<LanguageSelector> createState() => _LanguageSelectorState();
}

class _LanguageSelectorState extends State<LanguageSelector> {
  late Locale _selected;

  static const _languages = [
    (Locale('en'), 'English', 'English'),
    (Locale('ar'), 'العربية', 'Arabic'),
    (Locale('de'), 'Deutsch', 'German'),
    (Locale('es'), 'Español', 'Spanish'),
    (Locale('fr'), 'Français', 'French'),
    (Locale('ja'), '日本語', 'Japanese'),
    (Locale('zh'), '中文', 'Chinese'),
    (Locale('ko'), '한국어', 'Korean'),
  ];

  @override
  void initState() {
    super.initState();
    _selected = widget.currentLocale;
  }

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

    return Scaffold(
      appBar: AppBar(title: Text(l10n.languageSettingsTitle)),
      body: ListView(
        children: [
          Padding(
            padding: const EdgeInsetsDirectional.fromSTEB(16, 16, 16, 8),
            child: Text(
              l10n.selectLanguageHeading,
              style: Theme.of(context).textTheme.titleMedium,
            ),
          ),
          ..._languages.map((lang) {
            final (locale, nativeName, englishName) = lang;
            return RadioListTile<Locale>(
              title: Text(nativeName),
              subtitle: Text(englishName),
              value: locale,
              groupValue: _selected,
              onChanged: (v) {
                setState(() => _selected = v!);
                widget.onLocaleChanged(v!);
              },
            );
          }),
        ],
      ),
    );
  }
}

Sort Order Selection with Localized Options

List views with sorting options use RadioListTile groups with translated sort labels.

enum SortOrder { newest, oldest, alphabetical, popular }

class LocalizedSortOptions extends StatefulWidget {
  final SortOrder currentSort;
  final ValueChanged<SortOrder> onSortChanged;

  const LocalizedSortOptions({
    super.key,
    required this.currentSort,
    required this.onSortChanged,
  });

  @override
  State<LocalizedSortOptions> createState() => _LocalizedSortOptionsState();
}

class _LocalizedSortOptionsState extends State<LocalizedSortOptions> {
  late SortOrder _selected;

  @override
  void initState() {
    super.initState();
    _selected = widget.currentSort;
  }

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

    final options = [
      (SortOrder.newest, l10n.sortNewestFirst, Icons.arrow_downward),
      (SortOrder.oldest, l10n.sortOldestFirst, Icons.arrow_upward),
      (SortOrder.alphabetical, l10n.sortAlphabetical, Icons.sort_by_alpha),
      (SortOrder.popular, l10n.sortMostPopular, Icons.trending_up),
    ];

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: MainAxisSize.min,
      children: [
        Padding(
          padding: const EdgeInsetsDirectional.fromSTEB(16, 16, 16, 8),
          child: Text(
            l10n.sortByLabel,
            style: Theme.of(context).textTheme.titleMedium,
          ),
        ),
        ...options.map((option) {
          final (value, label, icon) = option;
          return RadioListTile<SortOrder>(
            title: Text(label),
            secondary: Icon(icon),
            value: value,
            groupValue: _selected,
            onChanged: (v) {
              setState(() => _selected = v!);
              widget.onSortChanged(v!);
            },
          );
        }),
      ],
    );
  }
}

Multi-Group Radio Selections

Settings screens may have multiple independent radio groups, each with localized headers.

enum FontSize { small, medium, large }
enum ContentDensity { compact, comfortable, spacious }

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

  @override
  State<MultiGroupRadioSettings> createState() =>
      _MultiGroupRadioSettingsState();
}

class _MultiGroupRadioSettingsState extends State<MultiGroupRadioSettings> {
  FontSize _fontSize = FontSize.medium;
  ContentDensity _density = ContentDensity.comfortable;

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

    return ListView(
      children: [
        Padding(
          padding: const EdgeInsetsDirectional.fromSTEB(16, 16, 16, 8),
          child: Text(
            l10n.fontSizeHeader,
            style: Theme.of(context).textTheme.titleSmall?.copyWith(
              color: Theme.of(context).colorScheme.primary,
            ),
          ),
        ),
        RadioListTile<FontSize>(
          title: Text(l10n.fontSizeSmall),
          value: FontSize.small,
          groupValue: _fontSize,
          onChanged: (v) => setState(() => _fontSize = v!),
        ),
        RadioListTile<FontSize>(
          title: Text(l10n.fontSizeMedium),
          value: FontSize.medium,
          groupValue: _fontSize,
          onChanged: (v) => setState(() => _fontSize = v!),
        ),
        RadioListTile<FontSize>(
          title: Text(l10n.fontSizeLarge),
          value: FontSize.large,
          groupValue: _fontSize,
          onChanged: (v) => setState(() => _fontSize = v!),
        ),
        const Divider(),
        Padding(
          padding: const EdgeInsetsDirectional.fromSTEB(16, 16, 16, 8),
          child: Text(
            l10n.contentDensityHeader,
            style: Theme.of(context).textTheme.titleSmall?.copyWith(
              color: Theme.of(context).colorScheme.primary,
            ),
          ),
        ),
        RadioListTile<ContentDensity>(
          title: Text(l10n.densityCompact),
          subtitle: Text(l10n.densityCompactDescription),
          value: ContentDensity.compact,
          groupValue: _density,
          onChanged: (v) => setState(() => _density = v!),
        ),
        RadioListTile<ContentDensity>(
          title: Text(l10n.densityComfortable),
          subtitle: Text(l10n.densityComfortableDescription),
          value: ContentDensity.comfortable,
          groupValue: _density,
          onChanged: (v) => setState(() => _density = v!),
        ),
        RadioListTile<ContentDensity>(
          title: Text(l10n.densitySpacious),
          subtitle: Text(l10n.densitySpaciousDescription),
          value: ContentDensity.spacious,
          groupValue: _density,
          onChanged: (v) => setState(() => _density = v!),
        ),
      ],
    );
  }
}

RTL Support and Bidirectional Layouts

RadioListTile automatically reverses its layout in RTL locales. The radio button moves to the start side, and text content aligns accordingly.

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

  @override
  State<BidirectionalRadioList> createState() =>
      _BidirectionalRadioListState();
}

class _BidirectionalRadioListState extends State<BidirectionalRadioList> {
  String _selected = 'option1';

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

    return Padding(
      padding: const EdgeInsetsDirectional.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            l10n.displayModeHeading,
            style: Theme.of(context).textTheme.titleMedium,
          ),
          RadioListTile<String>(
            title: Text(l10n.gridViewLabel),
            secondary: const Icon(Icons.grid_view),
            value: 'option1',
            groupValue: _selected,
            onChanged: (v) => setState(() => _selected = v!),
          ),
          RadioListTile<String>(
            title: Text(l10n.listViewLabel),
            secondary: const Icon(Icons.list),
            value: 'option2',
            groupValue: _selected,
            onChanged: (v) => setState(() => _selected = v!),
          ),
        ],
      ),
    );
  }
}

Testing RadioListTile 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 LocalizedRadioListTileExample(),
    );
  }

  testWidgets('RadioListTile displays translated labels', (tester) async {
    await tester.pumpWidget(buildTestWidget());
    await tester.pumpAndSettle();
    expect(find.byType(RadioListTile<ThemeMode>), findsNWidgets(3));
  });

  testWidgets('Selection changes on tap', (tester) async {
    await tester.pumpWidget(buildTestWidget());
    await tester.pumpAndSettle();
    await tester.tap(find.byType(RadioListTile<ThemeMode>).last);
    await tester.pumpAndSettle();
  });

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

Best Practices

  1. Use RadioListTile for language selection with native script names as titles and translated names as subtitles for clarity.

  2. Group radio options with localized section headers and Divider widgets to separate independent radio groups.

  3. Provide subtitles for complex options where the translated title alone may not convey the full meaning of the choice.

  4. Use secondary for descriptive icons that visually reinforce option meaning, reducing reliance on text for cross-cultural clarity.

  5. Test with verbose translations to verify that long option labels wrap without overlapping the radio control.

  6. Use enum types as generic parameters for type-safe radio groups, making the code self-documenting across locale-specific logic.

Conclusion

RadioListTile is the standard widget for single-select options in Flutter settings and preference screens. For multilingual apps, it provides automatic RTL layout reversal, flexible text wrapping for translated labels, and natural grouping for locale-aware preference categories. By implementing language selectors with native names, sort options with localized labels, and multi-group settings with translated headers, you can build RadioListTile interfaces that guide users clearly through choices in any supported language.

Further Reading