In order to make your Flutter app available for a broader audience it can be a very effective method to implement the dynamic translation of texts in your app. This way, people from different language backgrounds feel equally comfortable using your app.
Let’s implement a solution that fulfills the following requirements:
- Possibility of string interpolation (meaning translation of strings containing placeholders such as “Hello $name, how are you?”)
- Use localizations without BuildContext (I needed this to internationalize notifications when app is dismissed)
- Fall back to a default language when a particular string is not translated
- Use JSON as input format
And all that without the bloat of plurals, genders and that sort of stuff so that it stays easy, understandable and quick to be implemented.
Setup of the internationalization
Let’s start by making a change to the pubspec.yaml
. This is necessary as it gives us the possiblity to set the properties localizationDelegates
and supportedLocales
of MaterialApp:
Before
After
Adding internationalization files
Now we provide our JSON files that will contain the translated strings as assets. For that, we create a new folder called translations under a folder called assets
__(or however it suites your existing structure). I always structure it this way. In the assets folder aside from the translations
folder I have folders called images
and fonts
.
In this path you put files called de.json
and en.json
.
Let these files look like that:
assets/translations/en.json
assets/translations/de.json
We have to touch the pubspec.yaml
again, now that we know the path to our JSON files:
1flutter:
2
3 # The following line ensures that the Material Icons font is
4 # included with your application, so that you can use the icons in
5 # the material Icons class.
6 uses-material-design: true
7
8 # To add assets to your application, add an assets section, like this:
9 assets:
10 - assets/
11 - assets/translations/de.json # <--- THIS LINE WAS ADDED
12 - assets/translations/en.json # <--- THIS LINE WAS ADDED
Adding internationalization logic
We need to create a delegate now. This is a class that extends LocalizationsDelegate and thus has to provide three methods:
- isSupported – A method that returns true if the given locale is supported by our app. We return true because we want to return a string of our default language if it’s not localized in the current locale
- load – This method returns our
AppLocalization
class after it has finished loading - shouldReload – The docs say: “Returns true if the resources for this delegate should be loaded again by calling the load method.” so nothing we need right now
AppLocalizations
1import 'dart:ui';
2import 'package:flutter/material.dart';
3
4class AppLocalizations {
5 AppLocalizations(this.locale);
6
7 static AppLocalizations of(BuildContext context) {
8 return Localizations.of<AppLocalizations>(context, AppLocalizations);
9 }
10
11 static const LocalizationsDelegate<AppLocalizations> delegate =
12 _AppLocalizationsDelegate();
13
14 final Locale locale;
15 Map<String, String> _localizedStrings;
16
17 Future<void> load() async {
18 String jsonString = await rootBundle.loadString('assets/translations/${locale.languageCode}.json');
19 Map<String, dynamic> jsonMap = json.decode(jsonString);
20
21 _localizedStrings = jsonMap.map((key, value) {
22 return MapEntry(key, value.toString());
23 });
24 }
25
26 String translate(String key) {
27 return _localizedStrings[key];
28 }
29}
LocalizationsDelegate
1class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
2 const _AppLocalizationsDelegate();
3
4 @override
5 bool isSupported(Locale locale) {
6 return true;
7 }
8
9 @override
10 Future<AppLocalizations> load(Locale locale) async {
11 AppLocalizations localizations = new AppLocalizations(locale);
12 await localizations.load();
13 return localizations;
14 }
15
16 @override
17 bool shouldReload(_AppLocalizationsDelegate old) => false;
18}
Great. Now we have a basic setup for loading the translated strings from the JSON file using rootBundle.
The changes in the pubspec.yaml
enable us to use two new properties. Let’s use them:
1class MyApp extends StatelessWidget {
2 @override
3 Widget build(BuildContext context) {
4 return MaterialApp(
5 supportedLocales: [
6 const Locale('en', 'US'),
7 const Locale('de', 'DE'),
8 ],
9 localizationsDelegates: [
10 AppLocalizations.delegate,
11 GlobalMaterialLocalizations.delegate,
12 GlobalWidgetsLocalizations.delegate,
13 ],
14 home: MyHomePage(),
15 );
16 }
17}
Let’s have a closer look at the properties.
supportedLocales
supportedLocales
expects a list of locales. Actually, if we do not set it, it’s set to the default value which is a list only consisting of const Locale('en', 'US')
.
The order of the list elements matters. E.g. the first is taken as a fallback. Fore more information look at the docs.
localizationsDelegates
The docs say
The elements of the
localizationsDelegates
list are factories that produce collections of localized values.GlobalMaterialLocalizations.delegate
provides localized strings and other values for the Material Components library.GlobalWidgetsLocalizations.delegate
defines the default text direction, either left-to-right or right-to-left, for the widgets library.
So if we don’t care about left-to-right support for languages like Arabic or Hebrew, we can leave this out as.
However, if we omit GlobalMaterialLocalizations.delegate
, we’re faced with the following error:
1I/flutter (26681): Warning: This application's locale, de_DE, is not supported by all of its
2I/flutter (26681): localization delegates.
3I/flutter (26681): > A MaterialLocalizations delegate that supports the de_DE locale was not found.
So we should keep that property as it is.
Let’s have a look at the translation result
Alright, let’s use it like this:
1child: Center(
2 child: Text(
3 AppLocalizations.of(context).translate('test'),
4 style: TextStyle(
5 fontSize: 32
6 ),
7 )
8)
And the result is:
A first simple result …
That’s great. After having touched a few files, we are able to translate static strings in as many languages as we want by just providing JSON files with the respective keys in a certain directory. If that’s enough for you, you can stop here and take the code from here.
… but what about the dynamic internationalization strings?
One of the requirements I listed at the beginning was the ability to use string interpolation. So we want to turn Hey $name, how are you?
into Hey Test, how are you?
. It’s not that hard to achieve. The only thing we have to do is to let the translate
method expect a second optional parameter, a map where the keys are the placeholders of our translation strings and the values are the replacements:
1String translate(String key, [Map<String, String> arguments]) {
2 String translation = _localizedStrings[key] ?? '';
3 if (arguments == null || arguments.length == 0) {
4 return translation;
5 }
6 arguments.forEach((argumentKey, value) {
7 if (value == null) {
8 print('Value for "$argumentKey" is null in call of translate(\'$key\')');
9 value = '';
10 }
11 translation = translation.replaceAll("\$$argumentKey", value);
12 });
13 return translation;
14}
We provide arguments
as an optional (positional) argument to our method. Next we fetch the translation string from our _localizedStrings
that were initialized on load and set it to an empty string if nothing was found. If arguments
is not given or empty, we go for the static translation like before. Then we iterate over every value of the arguments
map and replace the respective occurrences of that key
prefixed with a $
sign in our translation string.
If we now change our main.dart
like this to test it:
1child: Center(
2 child: Text(
3 AppLocalizations.of(context).translate(
4 'string',
5 {'name': 'Test'}
6 ),
7 style: TextStyle(
8 fontSize: 32
9 ),
10 )
11)
We get this result:
What if a translated string is missing?
Let’s say we’re not only supporting English and German, but 50 different languages. The probability of a string not having been translated in any of the language files increases with the number of supported languages, I’d say.
That’s why we have to provide a fallback language. If in the current language no translated string could be found it should use the respective translation of the fallback language. If that fails as well, it should just return an empty string.
1class AppLocalizations {
2 AppLocalizations(this.locale);
3
4 static AppLocalizations of(BuildContext context) {
5 return Localizations.of<AppLocalizations>(context, AppLocalizations);
6 }
7
8 static const LocalizationsDelegate<AppLocalizations> delegate =
9 _AppLocalizationsDelegate();
10
11 final Locale locale;
12 Map<String, String> _localizedStrings;
13
14 Future<void> load() async {
15 String jsonString = await rootBundle.loadString('assets/translations/${locale.languageCode}.json');
16
17 Map<String, dynamic> jsonMap = json.decode(jsonString);
18
19 _localizedStrings = jsonMap.map((key, value) {
20 return MapEntry(key, value.toString());
21 });
22
23 return null;
24 }
25
26 String translate(String key) {
27 return _localizedStrings[key];
28 }
29}
30
31class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
32 const _AppLocalizationsDelegate();
33
34 @override
35 bool isSupported(Locale locale) {
36 return true;
37 }
38
39 @override
40 Future<AppLocalizations> load(Locale locale) async {
41 AppLocalizations localizations = new AppLocalizations(locale);
42 await localizations.load();
43 return localizations;
44 }
45
46 @override
47 bool shouldReload(_AppLocalizationsDelegate old) => false;
48}
1class AppLocalizations {
2 AppLocalizations(this.locale);
3 final Locale fallbackLocale = Locale('en');
4
5 static AppLocalizations of(BuildContext context) {
6 return Localizations.of<AppLocalizations>(context, AppLocalizations);
7 }
8
9 static const LocalizationsDelegate<AppLocalizations> delegate = _AppLocalizationsDelegate();
10
11 final Locale locale;
12 Map<String, String> _localizedStrings;
13 Map<String, String> _fallbackLocalizedStrings;
14
15 Future<void> load() async {
16 _localizedStrings = await _loadLocalizedStrings(locale);
17 _fallbackLocalizedStrings = {};
18
19 if (locale != fallbackLocale) {
20 _fallbackLocalizedStrings = await _loadLocalizedStrings(fallbackLocale);
21 }
22 }
23
24 Future<Map<String, String>> _loadLocalizedStrings(Locale localeToBeLoaded) async {
25 String jsonString;
26 Map<String, String> localizedStrings = {};
27
28 try {
29 jsonString = await rootBundle.loadString('assets/translations/${localeToBeLoaded.languageCode}.json');
30 } catch (exception) {
31 print(exception);
32 return localizedStrings;
33 }
34
35 Map<String, dynamic> jsonMap = json.decode(jsonString);
36
37 localizedStrings = jsonMap.map((key, value) {
38 return MapEntry(key, value.toString());
39 });
40
41 return localizedStrings;
42 }
43
44 String translate(String key, [Map<String, String> arguments]) {
45 String translation = _localizedStrings[key];
46 translation = translation ?? _fallbackLocalizedStrings[key];
47 translation = translation ?? "";
48
49 if (arguments == null || arguments.length == 0) {
50 return translation;
51 }
52
53 arguments.forEach((argumentKey, value) {
54 if (value == null) {
55 print('Value for "$argumentKey" is null in call of translate(\'$key\')');
56 value = '';
57 }
58 translation = translation.replaceAll("\$$argumentKey", value);
59 });
60
61 return translation;
62 }
63}
I have made the following changes:
- There is a new member variable
fallbackLocale
that defines which locale should be used if a translation in the current locale can not be found - Another new member variable is
_fallbackLocalizedStrings
. It’s supposed to store the localized strings of thefallbackLocale
- Now we need to fill
_fallbackLocalizedStrings
. We let that happen in theload
method when the delegate is initialized (only if the locale differs from the fallback language) - Lastly we need browse the
_fallbackLocalizedStrings
for a translation when there is no translation available in the current locale. That happens at the beginning of thetranslate
method
Now we have this cascade:
- If the translation of the given key is available in the current locale, take it. Else:
- If the translation of the fallback locale is available, take it. Else:
- Take an empty string
Translation without BuildContext
There are situations in which you want to get a translated string from outside a widget. Examples are:
- You want to call a helper class such as a a string formatter from a model. I used to have this situation where I had to call a date formatter in the fromJson constructor of a model. I have no build context inside a model
- You work with push notifications and want notifications that run in background to be translated by the app
We can make a few tiny adjustments to achieve that. Let’s edit the app_localizations.dart
again:
1static AppLocalizations instance;
2
3AppLocalizations._init(Locale locale) {
4 instance = this;
5 this.locale = locale;
6 }
A static field was added to the class that holds the current instance. Also, a private constructor was added that expects a locale and sets the instance. This feels a little bit hacky as it has the bad properties the (anti)pattern Singleton. I have not seen a better approach, though. Please comment if you know of a better way.
Now we need to ensure that the instance variable has a value when being called. Thus we use the new private constructor in the load method of our delegte.
1@override
2Future<AppLocalizations> load(Locale locale) async {
3 AppLocalizations localizations = AppLocalizations._init(locale);
4 await localizations.load();
5 return localizations;
6}
Now we can call the localization without a context:
1Text(
2 AppLocalizations.instance.translate(
3 'string_with_interpolation',
4 {'name': 'Test'}
5 )
6);
Special step for iOS
For supporting additional languages on iOS the Info.plist
needs to be updated. The content of the list should be consistent with the supportedLocales
parameter of our app. This can be done using Xcode. Just follow the instructions here.
Wrap up
We have come up with a simple and quick to be implemented approach for internationalization in Flutter.
- We can extend the translation by adding new properties to the JSON files of the respective language.
- We add support for new languages by adding new files (
the_language_code.json
) - We defined a fallback language that is used if a string is not translated
- We can use interpolated strings like “
Your username is $username
“ - If we want to get a translation from a place where no BuildContext is available, we can do that as well
Find the full code on Github in my gist:
Comment this 🤌