← Back to Blog

Flutter Change Language at Runtime: Complete Implementation Guide

flutterlocalizationruntimelanguage-switchstate-managementpersistence

Flutter Change Language at Runtime: Complete Implementation Guide

Want users to switch languages instantly without restarting your Flutter app? This guide covers everything you need to know about implementing dynamic locale switching with persistence, smooth transitions, and best practices.

What We'll Build

A Flutter app that:

  • Changes language instantly without restart
  • Persists user's language preference
  • Shows a smooth transition during language changes
  • Supports system locale detection
  • Works with any state management solution

Basic Implementation

Step 1: Setup Dependencies

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  intl: ^0.19.0
  shared_preferences: ^2.2.2

flutter:
  generate: true

Step 2: Create l10n.yaml

arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart

Step 3: Create a Locale Controller

// lib/controllers/locale_controller.dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

class LocaleController extends ChangeNotifier {
  static const String _storageKey = 'app_locale';

  Locale? _locale;
  Locale? get locale => _locale;

  // Supported locales
  static const List<Locale> supportedLocales = [
    Locale('en', 'US'),
    Locale('es', 'ES'),
    Locale('fr', 'FR'),
    Locale('de', 'DE'),
    Locale('ar', 'SA'),
    Locale('zh', 'CN'),
    Locale('ja', 'JP'),
  ];

  /// Initialize and load saved locale
  Future<void> initialize() async {
    final prefs = await SharedPreferences.getInstance();
    final savedLocale = prefs.getString(_storageKey);

    if (savedLocale != null) {
      final parts = savedLocale.split('_');
      _locale = Locale(
        parts[0],
        parts.length > 1 ? parts[1] : null,
      );
    }
    // If no saved locale, _locale remains null (use system default)
    notifyListeners();
  }

  /// Change the app locale
  Future<void> setLocale(Locale newLocale) async {
    if (_locale == newLocale) return;

    _locale = newLocale;
    notifyListeners();

    // Persist the selection
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(
      _storageKey,
      '${newLocale.languageCode}_${newLocale.countryCode ?? ''}',
    );
  }

  /// Reset to system locale
  Future<void> resetToSystemLocale() async {
    _locale = null;
    notifyListeners();

    final prefs = await SharedPreferences.getInstance();
    await prefs.remove(_storageKey);
  }

  /// Get display name for locale
  static String getDisplayName(Locale locale) {
    switch (locale.languageCode) {
      case 'en': return 'English';
      case 'es': return 'Español';
      case 'fr': return 'Français';
      case 'de': return 'Deutsch';
      case 'ar': return 'العربية';
      case 'zh': return '中文';
      case 'ja': return '日本語';
      default: return locale.languageCode;
    }
  }

  /// Get flag emoji for locale
  static String getFlag(Locale locale) {
    switch ('${locale.languageCode}_${locale.countryCode}') {
      case 'en_US': return '🇺🇸';
      case 'es_ES': return '🇪🇸';
      case 'fr_FR': return '🇫🇷';
      case 'de_DE': return '🇩🇪';
      case 'ar_SA': return '🇸🇦';
      case 'zh_CN': return '🇨🇳';
      case 'ja_JP': return '🇯🇵';
      default: return '🌐';
    }
  }
}

Step 4: Configure MaterialApp

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'controllers/locale_controller.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final localeController = LocaleController();
  await localeController.initialize();

  runApp(MyApp(localeController: localeController));
}

class MyApp extends StatefulWidget {
  final LocaleController localeController;

  const MyApp({super.key, required this.localeController});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();
    widget.localeController.addListener(_onLocaleChange);
  }

  @override
  void dispose() {
    widget.localeController.removeListener(_onLocaleChange);
    super.dispose();
  }

  void _onLocaleChange() {
    setState(() {}); // Rebuild app when locale changes
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Language Switcher Demo',
      debugShowCheckedModeBanner: false,

      // Dynamic locale from controller
      locale: widget.localeController.locale,

      // Localization setup
      localizationsDelegates: const [
        AppLocalizations.delegate,
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      supportedLocales: LocaleController.supportedLocales,

      // Locale resolution callback
      localeResolutionCallback: (deviceLocale, supportedLocales) {
        // If user selected a locale, use it
        if (widget.localeController.locale != null) {
          return widget.localeController.locale;
        }

        // Otherwise, try to match device locale
        for (final locale in supportedLocales) {
          if (locale.languageCode == deviceLocale?.languageCode) {
            return locale;
          }
        }

        // Fallback to first supported locale
        return supportedLocales.first;
      },

      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
        useMaterial3: true,
      ),

      home: HomePage(localeController: widget.localeController),
    );
  }
}

Step 5: Create Language Picker UI

// lib/widgets/language_picker.dart
import 'package:flutter/material.dart';
import '../controllers/locale_controller.dart';

class LanguagePicker extends StatelessWidget {
  final LocaleController localeController;

  const LanguagePicker({super.key, required this.localeController});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      shrinkWrap: true,
      itemCount: LocaleController.supportedLocales.length + 1,
      itemBuilder: (context, index) {
        // First item: System Default
        if (index == 0) {
          return _buildSystemDefaultTile(context);
        }

        final locale = LocaleController.supportedLocales[index - 1];
        return _buildLanguageTile(context, locale);
      },
    );
  }

  Widget _buildSystemDefaultTile(BuildContext context) {
    final isSelected = localeController.locale == null;

    return ListTile(
      leading: const Text('🌐', style: TextStyle(fontSize: 24)),
      title: const Text('System Default'),
      subtitle: Text(
        'Uses device language settings',
        style: TextStyle(
          color: Theme.of(context).colorScheme.outline,
        ),
      ),
      trailing: isSelected
          ? Icon(Icons.check_circle, color: Theme.of(context).primaryColor)
          : null,
      onTap: () {
        localeController.resetToSystemLocale();
        Navigator.pop(context);
      },
    );
  }

  Widget _buildLanguageTile(BuildContext context, Locale locale) {
    final isSelected = localeController.locale?.languageCode == locale.languageCode;

    return ListTile(
      leading: Text(
        LocaleController.getFlag(locale),
        style: const TextStyle(fontSize: 24),
      ),
      title: Text(LocaleController.getDisplayName(locale)),
      subtitle: Text(locale.toString()),
      trailing: isSelected
          ? Icon(Icons.check_circle, color: Theme.of(context).primaryColor)
          : null,
      onTap: () {
        localeController.setLocale(locale);
        Navigator.pop(context);
      },
    );
  }
}

// Bottom sheet helper
void showLanguagePicker(BuildContext context, LocaleController controller) {
  showModalBottomSheet(
    context: context,
    isScrollControlled: true,
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
    ),
    builder: (context) => DraggableScrollableSheet(
      initialChildSize: 0.6,
      minChildSize: 0.3,
      maxChildSize: 0.9,
      expand: false,
      builder: (context, scrollController) => Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: Text(
              'Select Language',
              style: Theme.of(context).textTheme.titleLarge,
            ),
          ),
          const Divider(),
          Expanded(
            child: LanguagePicker(localeController: controller),
          ),
        ],
      ),
    ),
  );
}

Advanced: Animated Locale Transition

Add a smooth fade transition when changing languages:

// lib/widgets/locale_transition.dart
import 'package:flutter/material.dart';

class LocaleTransition extends StatefulWidget {
  final Widget child;
  final Locale? locale;

  const LocaleTransition({
    super.key,
    required this.child,
    required this.locale,
  });

  @override
  State<LocaleTransition> createState() => _LocaleTransitionState();
}

class _LocaleTransitionState extends State<LocaleTransition>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;
  Locale? _previousLocale;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 300),
      vsync: this,
    );
    _animation = CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    );
    _previousLocale = widget.locale;
  }

  @override
  void didUpdateWidget(LocaleTransition oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (oldWidget.locale != widget.locale) {
      _controller.forward(from: 0);
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return FadeTransition(
      opacity: Tween<double>(begin: 0.5, end: 1.0).animate(_animation),
      child: widget.child,
    );
  }
}

Using with Different State Management

With Provider

// Using ChangeNotifierProvider
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => LocaleController()..initialize(),
      child: Consumer<LocaleController>(
        builder: (context, localeController, _) {
          return MaterialApp(
            locale: localeController.locale,
            // ...
          );
        },
      ),
    );
  }
}

With Riverpod

// Locale notifier
final localeProvider = StateNotifierProvider<LocaleNotifier, Locale?>((ref) {
  return LocaleNotifier();
});

class LocaleNotifier extends StateNotifier<Locale?> {
  LocaleNotifier() : super(null) {
    _loadSavedLocale();
  }

  Future<void> _loadSavedLocale() async {
    final prefs = await SharedPreferences.getInstance();
    final saved = prefs.getString('locale');
    if (saved != null) {
      state = Locale(saved);
    }
  }

  Future<void> setLocale(Locale locale) async {
    state = locale;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('locale', locale.languageCode);
  }
}

// In your app
class MyApp extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final locale = ref.watch(localeProvider);

    return MaterialApp(
      locale: locale,
      // ...
    );
  }
}

With BLoC

// locale_bloc.dart
class LocaleBloc extends Bloc<LocaleEvent, Locale?> {
  LocaleBloc() : super(null) {
    on<LoadLocale>(_onLoadLocale);
    on<ChangeLocale>(_onChangeLocale);
  }

  Future<void> _onLoadLocale(LoadLocale event, Emitter<Locale?> emit) async {
    final prefs = await SharedPreferences.getInstance();
    final saved = prefs.getString('locale');
    if (saved != null) {
      emit(Locale(saved));
    }
  }

  Future<void> _onChangeLocale(ChangeLocale event, Emitter<Locale?> emit) async {
    emit(event.locale);
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('locale', event.locale.languageCode);
  }
}

// In your app
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => LocaleBloc()..add(LoadLocale()),
      child: BlocBuilder<LocaleBloc, Locale?>(
        builder: (context, locale) {
          return MaterialApp(
            locale: locale,
            // ...
          );
        },
      ),
    );
  }
}

Complete Language Selector Dialog

// lib/widgets/language_selector_dialog.dart
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../controllers/locale_controller.dart';

class LanguageSelectorDialog extends StatelessWidget {
  final LocaleController localeController;

  const LanguageSelectorDialog({
    super.key,
    required this.localeController,
  });

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

    return AlertDialog(
      title: Row(
        children: [
          const Icon(Icons.language),
          const SizedBox(width: 8),
          Text(l10n.selectLanguage),
        ],
      ),
      content: SizedBox(
        width: double.maxFinite,
        child: ListView.separated(
          shrinkWrap: true,
          itemCount: LocaleController.supportedLocales.length,
          separatorBuilder: (_, __) => const Divider(height: 1),
          itemBuilder: (context, index) {
            final locale = LocaleController.supportedLocales[index];
            final isSelected = currentLocale.languageCode == locale.languageCode;

            return ListTile(
              leading: Text(
                LocaleController.getFlag(locale),
                style: const TextStyle(fontSize: 28),
              ),
              title: Text(
                LocaleController.getDisplayName(locale),
                style: TextStyle(
                  fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
                ),
              ),
              trailing: isSelected
                  ? const Icon(Icons.check, color: Colors.green)
                  : null,
              selected: isSelected,
              onTap: () {
                localeController.setLocale(locale);
                Navigator.pop(context);
              },
            );
          },
        ),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: Text(l10n.cancel),
        ),
      ],
    );
  }
}

// Helper function to show dialog
void showLanguageDialog(BuildContext context, LocaleController controller) {
  showDialog(
    context: context,
    builder: (context) => LanguageSelectorDialog(localeController: controller),
  );
}

Inline Language Switcher Widget

A dropdown button for headers or settings:

class LanguageDropdown extends StatelessWidget {
  final LocaleController localeController;

  const LanguageDropdown({super.key, required this.localeController});

  @override
  Widget build(BuildContext context) {
    final currentLocale = Localizations.localeOf(context);

    return PopupMenuButton<Locale>(
      initialValue: currentLocale,
      icon: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(LocaleController.getFlag(currentLocale)),
          const Icon(Icons.arrow_drop_down),
        ],
      ),
      onSelected: (locale) => localeController.setLocale(locale),
      itemBuilder: (context) => LocaleController.supportedLocales
          .map((locale) => PopupMenuItem(
                value: locale,
                child: Row(
                  children: [
                    Text(LocaleController.getFlag(locale)),
                    const SizedBox(width: 8),
                    Text(LocaleController.getDisplayName(locale)),
                  ],
                ),
              ))
          .toList(),
    );
  }
}

Best Practices

1. Always Persist User Choice

// Save immediately when user selects
await prefs.setString('locale', locale.languageCode);

2. Respect System Settings by Default

// Return null to use system locale
localeResolutionCallback: (deviceLocale, supportedLocales) {
  if (_userSelectedLocale != null) return _userSelectedLocale;
  return deviceLocale;  // Use system setting
},

3. Show Confirmation for RTL Changes

void setLocale(Locale locale) {
  final isRtlChange = _isRtl(_currentLocale) != _isRtl(locale);

  if (isRtlChange) {
    showDialog(
      // Confirm RTL/LTR switch as it changes entire layout
    );
  } else {
    _applyLocale(locale);
  }
}

4. Preload Fonts for New Scripts

// In your app initialization
await GoogleFonts.pendingFonts([
  GoogleFonts.notoSansArabic(),
  GoogleFonts.notoSansSC(), // Chinese
  GoogleFonts.notoSansJP(), // Japanese
]);

FlutterLocalisation: Simplified Language Management

Managing translations across multiple languages for runtime switching is complex. FlutterLocalisation streamlines this with:

  • Visual Language Manager: See all languages at a glance
  • AI Translations: One-click translation to new languages
  • Auto-Sync: ARB files stay in sync with your Git repository
  • Missing Key Detection: Know instantly if a language is incomplete

Conclusion

Implementing runtime language switching in Flutter involves:

  1. A locale controller to manage state
  2. Persisting user preferences with SharedPreferences
  3. Configuring MaterialApp with locale and resolution callback
  4. Building intuitive language picker UI

With this implementation, users can switch languages instantly, and their preference persists across sessions.


Building a multilingual Flutter app? Try FlutterLocalisation free and manage translations across all your supported languages with AI-powered automation and real-time collaboration.