Dynamic language switching in Flutter at runtime
Changing language at runtime — while the app is open, without restart — requires more than just updating the MaterialApp.locale. You need to update the locale, persist the choice, and rebuild the app. Here is how to do it cleanly.
Setup
If you haven't done basic i18n setup yet, start there. This article assumes you have ARB files and generated AppLocalizations.
dependencies:
flutter_localizations:
sdk: flutter
intl: ^0.19.0
shared_preferences: ^2.2.3
Locale provider with Riverpod
// Read persisted locale on app start
@riverpod
Future<Locale?> persistedLocale(PersistedLocaleRef ref) async {
final prefs = await SharedPreferences.getInstance();
final code = prefs.getString('locale');
return code != null ? Locale(code) : null;
}
// Current locale: starts null (uses system locale), can be overridden
@riverpod
class LocaleNotifier extends _$LocaleNotifier {
@override
Locale? build() => null; // null = use system locale
Future<void> setLocale(Locale locale) async {
state = locale;
// Persist
final prefs = await SharedPreferences.getInstance();
await prefs.setString('locale', locale.languageCode);
}
Future<void> clearLocale() async {
state = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove('locale');
}
}
Loading persisted locale at startup
class App extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Load persisted locale on startup
final persistedLocale = ref.watch(persistedLocaleProvider);
return persistedLocale.when(
data: (locale) {
// Initialize the notifier with the persisted locale
if (locale != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(localeNotifierProvider.notifier).setLocale(locale);
});
}
return _AppWidget();
},
loading: () => const MaterialApp(home: Scaffold(body: Center(child: CircularProgressIndicator()))),
error: (_, __) => _AppWidget(),
);
}
}
class _AppWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final locale = ref.watch(localeNotifierProvider);
return MaterialApp(
locale: locale, // null = use system locale
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
// ...
);
}
}
Language selector widget
class LanguageSelectorScreen extends ConsumerWidget {
const LanguageSelectorScreen({super.key});
static const _supportedLocales = [
(locale: Locale('en'), name: 'English', nativeName: 'English'),
(locale: Locale('fr'), name: 'French', nativeName: 'Français'),
(locale: Locale('de'), name: 'German', nativeName: 'Deutsch'),
(locale: Locale('ar'), name: 'Arabic', nativeName: 'العربية'),
(locale: Locale('ja'), name: 'Japanese', nativeName: '日本語'),
];
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentLocale = ref.watch(localeNotifierProvider);
return Scaffold(
appBar: AppBar(title: const Text('Language')),
body: Column(
children: [
// System default option
ListTile(
title: const Text('System default'),
trailing: currentLocale == null
? const Icon(Icons.check, color: Colors.blue)
: null,
onTap: () => ref.read(localeNotifierProvider.notifier).clearLocale(),
),
const Divider(),
..._supportedLocales.map((lang) => ListTile(
title: Text(lang.nativeName),
subtitle: Text(lang.name),
trailing: currentLocale?.languageCode == lang.locale.languageCode
? const Icon(Icons.check, color: Colors.blue)
: null,
onTap: () => ref.read(localeNotifierProvider.notifier).setLocale(lang.locale),
)),
],
),
);
}
}
RTL support
When switching to Arabic or Hebrew (RTL languages), Flutter automatically mirrors the layout if you've used EdgeInsetsDirectional and TextDirection-aware widgets.
Verify RTL by adding a quick test language in your debug builds:
// Debug build: force RTL
if (kDebugMode) {
MaterialApp(
locale: const Locale('ar'),
// ...
);
}
Date and number formatting
Date/number format changes when the locale changes — make sure to use locale-aware formatting:
final locale = Localizations.localeOf(context).toString();
// These automatically use the current locale:
final dateStr = DateFormat.yMMMd(locale).format(date);
final priceStr = NumberFormat.currency(locale: locale, symbol: '\$').format(price);
Testing locale switching
testWidgets('switches to French correctly', (tester) async {
await tester.pumpWidget(
ProviderScope(
child: const MaterialApp(
locale: Locale('fr'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: HomeScreen(),
),
),
);
// Check French strings are shown
expect(find.text('Bonjour'), findsOneWidget);
});
Common pitfalls
Not persisting the locale. If you set the locale via state but don't persist it, the user has to reselect their language every time they open the app.
Using Localizations.localeOf(context) in providers. Providers don't have a BuildContext. Use the locale from your localeNotifierProvider directly rather than from Localizations.localeOf.
Forgetting to update intl formatting. Switching the app locale changes AppLocalizations strings, but DateFormat and NumberFormat use whatever locale string you pass. Always read locale from context and pass it to format functions.
Sign in to like, dislike, or report.