Flutter InheritedWidget Localization: Custom Delegates and Advanced Architecture Patterns
InheritedWidget is the foundation of Flutter's localization system. Understanding how to create custom localization delegates and leverage InheritedWidget patterns gives you complete control over translation loading, caching, and context-free access. This guide covers advanced localization architecture using InheritedWidget.
Understanding InheritedWidget in Localization
InheritedWidget enables:
- Efficient data propagation down the widget tree
- Automatic rebuilds when localization data changes
- Custom localization delegates for remote translations
- Context-free translation access through static methods
- Lazy loading of language resources
- Fine-grained control over when widgets rebuild
How Flutter's Built-in Localization Works
Before creating custom solutions, understand the existing system:
// Flutter's localization is built on InheritedWidget
// The Localizations widget provides this hierarchy:
// 1. Localizations (StatefulWidget)
// └── _LocalizationsScope (InheritedWidget)
// └── Your app widgets
// Access translations via:
AppLocalizations.of(context)!.someTranslation;
// Which internally does:
// Localizations.of<AppLocalizations>(context, AppLocalizations)
Creating a Custom Localization InheritedWidget
Build your own localization system:
import 'package:flutter/material.dart';
// 1. Define your translations model
class AppTranslations {
final String locale;
final Map<String, String> _translations;
AppTranslations({
required this.locale,
required Map<String, String> translations,
}) : _translations = translations;
String translate(String key) {
return _translations[key] ?? key;
}
// Convenience getters for type-safe access
String get appTitle => translate('appTitle');
String get welcomeMessage => translate('welcomeMessage');
String get loginButton => translate('loginButton');
String get logoutButton => translate('logoutButton');
// Parameterized translations
String greeting(String name) {
return translate('greeting').replaceAll('{name}', name);
}
String itemCount(int count) {
if (count == 0) return translate('noItems');
if (count == 1) return translate('oneItem');
return translate('multipleItems').replaceAll('{count}', count.toString());
}
}
// 2. Create the InheritedWidget
class LocalizationProvider extends InheritedWidget {
final AppTranslations translations;
final Locale currentLocale;
final void Function(Locale) setLocale;
const LocalizationProvider({
super.key,
required this.translations,
required this.currentLocale,
required this.setLocale,
required super.child,
});
// Static accessor for convenience
static LocalizationProvider of(BuildContext context) {
final provider =
context.dependOnInheritedWidgetOfExactType<LocalizationProvider>();
if (provider == null) {
throw FlutterError(
'LocalizationProvider not found in context. '
'Make sure to wrap your app with LocalizationScope.',
);
}
return provider;
}
// Get translations without context dependency (won't rebuild on changes)
static LocalizationProvider? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<LocalizationProvider>();
}
@override
bool updateShouldNotify(LocalizationProvider oldWidget) {
return translations.locale != oldWidget.translations.locale;
}
}
// 3. Create a StatefulWidget to manage locale state
class LocalizationScope extends StatefulWidget {
final Widget child;
final Locale initialLocale;
final Future<Map<String, String>> Function(String locale) loadTranslations;
const LocalizationScope({
super.key,
required this.child,
required this.initialLocale,
required this.loadTranslations,
});
@override
State<LocalizationScope> createState() => _LocalizationScopeState();
}
class _LocalizationScopeState extends State<LocalizationScope> {
late Locale _currentLocale;
AppTranslations? _translations;
bool _isLoading = true;
@override
void initState() {
super.initState();
_currentLocale = widget.initialLocale;
_loadTranslations(_currentLocale.languageCode);
}
Future<void> _loadTranslations(String locale) async {
setState(() => _isLoading = true);
try {
final translations = await widget.loadTranslations(locale);
setState(() {
_translations = AppTranslations(
locale: locale,
translations: translations,
);
_isLoading = false;
});
} catch (e) {
// Fall back to empty translations
setState(() {
_translations = AppTranslations(
locale: locale,
translations: {},
);
_isLoading = false;
});
}
}
void _setLocale(Locale locale) {
if (locale != _currentLocale) {
_currentLocale = locale;
_loadTranslations(locale.languageCode);
}
}
@override
Widget build(BuildContext context) {
if (_isLoading || _translations == null) {
return const MaterialApp(
home: Scaffold(
body: Center(child: CircularProgressIndicator()),
),
);
}
return LocalizationProvider(
translations: _translations!,
currentLocale: _currentLocale,
setLocale: _setLocale,
child: widget.child,
);
}
}
Loading Remote Translations
Implement a translation loader that fetches from an API:
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class RemoteTranslationLoader {
final String baseUrl;
final Duration cacheExpiry;
RemoteTranslationLoader({
required this.baseUrl,
this.cacheExpiry = const Duration(hours: 24),
});
Future<Map<String, String>> loadTranslations(String locale) async {
// Try cache first
final cached = await _getCachedTranslations(locale);
if (cached != null) {
return cached;
}
// Fetch from remote
try {
final response = await http.get(
Uri.parse('$baseUrl/translations/$locale.json'),
);
if (response.statusCode == 200) {
final Map<String, dynamic> data = json.decode(response.body);
final translations = data.map(
(key, value) => MapEntry(key, value.toString()),
);
// Cache the result
await _cacheTranslations(locale, translations);
return translations;
}
} catch (e) {
debugPrint('Failed to load remote translations: $e');
}
// Fall back to bundled translations
return _loadBundledTranslations(locale);
}
Future<Map<String, String>?> _getCachedTranslations(String locale) async {
final prefs = await SharedPreferences.getInstance();
final cacheKey = 'translations_$locale';
final timestampKey = 'translations_timestamp_$locale';
final cached = prefs.getString(cacheKey);
final timestamp = prefs.getInt(timestampKey);
if (cached == null || timestamp == null) return null;
final cacheTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
if (DateTime.now().difference(cacheTime) > cacheExpiry) {
return null; // Cache expired
}
final Map<String, dynamic> data = json.decode(cached);
return data.map((key, value) => MapEntry(key, value.toString()));
}
Future<void> _cacheTranslations(
String locale,
Map<String, String> translations,
) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'translations_$locale',
json.encode(translations),
);
await prefs.setInt(
'translations_timestamp_$locale',
DateTime.now().millisecondsSinceEpoch,
);
}
Future<Map<String, String>> _loadBundledTranslations(String locale) async {
// Load from assets as fallback
// This would typically use rootBundle.loadString
return {
'appTitle': 'My App',
'welcomeMessage': 'Welcome',
'loginButton': 'Log In',
'logoutButton': 'Log Out',
};
}
}
Context-Free Translation Access
Access translations without BuildContext using a singleton:
import 'package:flutter/material.dart';
class L10n {
static final L10n _instance = L10n._internal();
factory L10n() => _instance;
L10n._internal();
// Store the current translations
AppTranslations? _translations;
BuildContext? _context;
// Initialize with context (call from app root)
void init(BuildContext context) {
_context = context;
_updateTranslations();
}
void _updateTranslations() {
if (_context != null) {
final provider = LocalizationProvider.maybeOf(_context!);
_translations = provider?.translations;
}
}
// Get current translations
AppTranslations get translations {
_updateTranslations();
if (_translations == null) {
throw StateError(
'L10n not initialized. Call L10n().init(context) first.',
);
}
return _translations!;
}
// Convenience static accessors
static String get appTitle => L10n().translations.appTitle;
static String get welcomeMessage => L10n().translations.welcomeMessage;
static String greeting(String name) => L10n().translations.greeting(name);
}
// Usage in services (no context needed):
class AuthService {
Future<void> logout() async {
// Access translations without context
final message = L10n.translations.logoutButton;
debugPrint('Logging out: $message');
}
}
// Initialize in your app:
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return LocalizationScope(
initialLocale: const Locale('en'),
loadTranslations: RemoteTranslationLoader(
baseUrl: 'https://api.example.com',
).loadTranslations,
child: Builder(
builder: (context) {
// Initialize L10n singleton
L10n().init(context);
return MaterialApp(
home: const HomeScreen(),
);
},
),
);
}
}
Creating a Custom LocalizationsDelegate
Integrate with Flutter's official localization system:
import 'package:flutter/material.dart';
class CustomAppLocalizations {
final Locale locale;
final Map<String, String> _translations;
CustomAppLocalizations(this.locale, this._translations);
static CustomAppLocalizations of(BuildContext context) {
return Localizations.of<CustomAppLocalizations>(
context,
CustomAppLocalizations,
)!;
}
String translate(String key) => _translations[key] ?? key;
// Type-safe getters
String get appTitle => translate('appTitle');
String get welcomeMessage => translate('welcomeMessage');
}
class CustomLocalizationsDelegate
extends LocalizationsDelegate<CustomAppLocalizations> {
final Future<Map<String, String>> Function(String locale) loadTranslations;
final List<Locale> supportedLocales;
const CustomLocalizationsDelegate({
required this.loadTranslations,
required this.supportedLocales,
});
@override
bool isSupported(Locale locale) {
return supportedLocales.any(
(supported) => supported.languageCode == locale.languageCode,
);
}
@override
Future<CustomAppLocalizations> load(Locale locale) async {
final translations = await loadTranslations(locale.languageCode);
return CustomAppLocalizations(locale, translations);
}
@override
bool shouldReload(CustomLocalizationsDelegate old) => false;
}
// Usage in MaterialApp:
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
final loader = RemoteTranslationLoader(
baseUrl: 'https://api.example.com',
);
return MaterialApp(
localizationsDelegates: [
CustomLocalizationsDelegate(
loadTranslations: loader.loadTranslations,
supportedLocales: const [
Locale('en'),
Locale('es'),
Locale('de'),
Locale('fr'),
],
),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('en'),
Locale('es'),
Locale('de'),
Locale('fr'),
],
home: const HomeScreen(),
);
}
}
Selective Rebuilding with InheritedModel
Use InheritedModel for fine-grained rebuilds:
import 'package:flutter/material.dart';
enum LocalizationAspect {
locale,
translations,
all,
}
class LocalizationModel extends InheritedModel<LocalizationAspect> {
final Locale locale;
final AppTranslations translations;
final void Function(Locale) setLocale;
const LocalizationModel({
super.key,
required this.locale,
required this.translations,
required this.setLocale,
required super.child,
});
static LocalizationModel of(
BuildContext context, {
LocalizationAspect aspect = LocalizationAspect.all,
}) {
return InheritedModel.inheritFrom<LocalizationModel>(
context,
aspect: aspect,
)!;
}
// Only get translations (won't rebuild on locale change alone)
static AppTranslations translationsOf(BuildContext context) {
return of(context, aspect: LocalizationAspect.translations).translations;
}
// Only get locale (won't rebuild on translation changes)
static Locale localeOf(BuildContext context) {
return of(context, aspect: LocalizationAspect.locale).locale;
}
@override
bool updateShouldNotify(LocalizationModel oldWidget) {
return locale != oldWidget.locale ||
translations.locale != oldWidget.translations.locale;
}
@override
bool updateShouldNotifyDependent(
LocalizationModel oldWidget,
Set<LocalizationAspect> dependencies,
) {
if (dependencies.contains(LocalizationAspect.all)) {
return locale != oldWidget.locale ||
translations.locale != oldWidget.translations.locale;
}
if (dependencies.contains(LocalizationAspect.locale)) {
if (locale != oldWidget.locale) return true;
}
if (dependencies.contains(LocalizationAspect.translations)) {
if (translations.locale != oldWidget.translations.locale) return true;
}
return false;
}
}
// Usage - only rebuilds when translations change:
class TranslatedText extends StatelessWidget {
final String translationKey;
const TranslatedText(this.translationKey, {super.key});
@override
Widget build(BuildContext context) {
// Only depends on translations aspect
final translations = LocalizationModel.translationsOf(context);
return Text(translations.translate(translationKey));
}
}
// Usage - only rebuilds when locale changes:
class LocaleDisplay extends StatelessWidget {
const LocaleDisplay({super.key});
@override
Widget build(BuildContext context) {
// Only depends on locale aspect
final locale = LocalizationModel.localeOf(context);
return Text('Current: ${locale.languageCode}');
}
}
Lazy Loading Translations per Route
Load translations only when needed:
import 'package:flutter/material.dart';
class RouteTranslations extends InheritedWidget {
final Map<String, String> translations;
final String routeName;
const RouteTranslations({
super.key,
required this.translations,
required this.routeName,
required super.child,
});
static RouteTranslations? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<RouteTranslations>();
}
String translate(String key) => translations[key] ?? key;
@override
bool updateShouldNotify(RouteTranslations oldWidget) {
return routeName != oldWidget.routeName;
}
}
class LazyLocalizedRoute extends StatefulWidget {
final String routeName;
final Widget Function(BuildContext context) builder;
final Future<Map<String, String>> Function(String route, String locale)
loadRouteTranslations;
const LazyLocalizedRoute({
super.key,
required this.routeName,
required this.builder,
required this.loadRouteTranslations,
});
@override
State<LazyLocalizedRoute> createState() => _LazyLocalizedRouteState();
}
class _LazyLocalizedRouteState extends State<LazyLocalizedRoute> {
Map<String, String>? _translations;
bool _isLoading = true;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_loadTranslations();
}
Future<void> _loadTranslations() async {
final locale = Localizations.localeOf(context).languageCode;
final translations = await widget.loadRouteTranslations(
widget.routeName,
locale,
);
if (mounted) {
setState(() {
_translations = translations;
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
return RouteTranslations(
routeName: widget.routeName,
translations: _translations ?? {},
child: Builder(builder: widget.builder),
);
}
}
// Usage in routes:
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return LazyLocalizedRoute(
routeName: 'settings',
loadRouteTranslations: (route, locale) async {
// Load only settings translations
final response = await http.get(
Uri.parse('https://api.example.com/translations/$locale/$route.json'),
);
return Map<String, String>.from(json.decode(response.body));
},
builder: (context) {
final routeL10n = RouteTranslations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(routeL10n.translate('settingsTitle')),
),
body: ListView(
children: [
ListTile(
title: Text(routeL10n.translate('accountSettings')),
),
ListTile(
title: Text(routeL10n.translate('privacySettings')),
),
],
),
);
},
);
}
}
Testing Custom Localization
Write comprehensive tests:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('LocalizationProvider', () {
testWidgets('provides translations to descendants', (tester) async {
final translations = AppTranslations(
locale: 'en',
translations: {'greeting': 'Hello'},
);
await tester.pumpWidget(
LocalizationProvider(
translations: translations,
currentLocale: const Locale('en'),
setLocale: (_) {},
child: Builder(
builder: (context) {
final provider = LocalizationProvider.of(context);
return Text(provider.translations.translate('greeting'));
},
),
),
);
expect(find.text('Hello'), findsOneWidget);
});
testWidgets('rebuilds when locale changes', (tester) async {
late void Function(Locale) setLocale;
await tester.pumpWidget(
MaterialApp(
home: StatefulBuilder(
builder: (context, setState) {
var translations = AppTranslations(
locale: 'en',
translations: {'greeting': 'Hello'},
);
var locale = const Locale('en');
setLocale = (newLocale) {
setState(() {
locale = newLocale;
translations = AppTranslations(
locale: newLocale.languageCode,
translations: {'greeting': 'Hola'},
);
});
};
return LocalizationProvider(
translations: translations,
currentLocale: locale,
setLocale: setLocale,
child: Builder(
builder: (context) {
final provider = LocalizationProvider.of(context);
return Text(provider.translations.translate('greeting'));
},
),
);
},
),
),
);
expect(find.text('Hello'), findsOneWidget);
setLocale(const Locale('es'));
await tester.pumpAndSettle();
expect(find.text('Hola'), findsOneWidget);
});
testWidgets('throws error when not in tree', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
expect(
() => LocalizationProvider.of(context),
throwsA(isA<FlutterError>()),
);
return const SizedBox();
},
),
),
);
});
});
group('CustomLocalizationsDelegate', () {
testWidgets('loads translations for supported locale', (tester) async {
final delegate = CustomLocalizationsDelegate(
loadTranslations: (locale) async {
return {'appTitle': 'Test App ($locale)'};
},
supportedLocales: const [Locale('en'), Locale('es')],
);
expect(delegate.isSupported(const Locale('en')), isTrue);
expect(delegate.isSupported(const Locale('es')), isTrue);
expect(delegate.isSupported(const Locale('fr')), isFalse);
final localizations = await delegate.load(const Locale('en'));
expect(localizations.appTitle, equals('Test App (en)'));
});
});
}
Summary
Using InheritedWidget for Flutter localization provides:
- Custom translation loading from any source (remote API, database, etc.)
- Fine-grained rebuild control with InheritedModel
- Context-free access through singletons or static methods
- Lazy loading of translations per route or feature
- Caching strategies with expiration policies
- Fallback mechanisms for offline or failed loads
- Complete testing of localization infrastructure
Understanding InheritedWidget patterns gives you the flexibility to build localization systems tailored to your app's specific needs while maintaining the efficiency and developer experience of Flutter's widget model.