Internationalization (i18n) in Flutter: an easy and quick approach

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

dependencies:
  flutter:
    sdk: flutter

After

dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter

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

{
  "string": "Impressive!",
  "string_with_interpolation": "Hey $name, how are you?"
}

assets/translations/de.json

{
  "string": "Beeindruckend!",
  "string_with_interpolation": "Hey $name, wie geht es dir?"
}

We have to touch the pubspec.yaml again, now that we know the path to our JSON files:

flutter:

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true

  # To add assets to your application, add an assets section, like this:
  assets:
   - assets/
   - assets/translations/de.json # <--- THIS LINE WAS ADDED
   - 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

import 'dart:ui';
import 'package:flutter/material.dart';

class AppLocalizations {
  AppLocalizations(this.locale);

  static AppLocalizations of(BuildContext context) {
    return Localizations.of<AppLocalizations>(context, AppLocalizations);
  }

  static const LocalizationsDelegate<AppLocalizations> delegate =
  _AppLocalizationsDelegate();
  
  final Locale locale;
  Map<String, String> _localizedStrings;

  Future<void> load() async {
    String jsonString = await rootBundle.loadString('assets/translations/${locale.languageCode}.json');
    Map<String, dynamic> jsonMap = json.decode(jsonString);

    _localizedStrings = jsonMap.map((key, value) {
      return MapEntry(key, value.toString());
    });
  }

  String translate(String key) {
    return _localizedStrings[key];
  }
}

LocalizationsDelegate

class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
  const _AppLocalizationsDelegate();

  @override
  bool isSupported(Locale locale) {
    return true;
  }

  @override
  Future<AppLocalizations> load(Locale locale) async {
    AppLocalizations localizations = new AppLocalizations(locale);
    await localizations.load();
    return localizations;
  }

  @override
  bool shouldReload(_AppLocalizationsDelegate old) => false;
}

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:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      supportedLocales: [
       const Locale('en', 'US'),
       const Locale('de', 'DE'),
      ],
      localizationsDelegates: [
        AppLocalizations.delegate,
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
      ],
      home: MyHomePage(),
    );
  }
}

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:

I/flutter (26681): Warning: This application's locale, de_DE, is not supported by all of its
I/flutter (26681): localization delegates.
I/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:

child: Center(
  child: Text(
    AppLocalizations.of(context).translate('test'),
    style: TextStyle(
      fontSize: 32
    ),
  )
)

And the result is:

Main screen using our localization
Main screen
Main screen using our localization (locale: de)
Screen after having switched to German language

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:

String translate(String key, [Map<String, String> arguments]) {
  String translation = _localizedStrings[key] ?? '';
  if (arguments == null || arguments.length == 0) {
    return translation;
  }
  arguments.forEach((argumentKey, value) {
    if (value == null) {
      print('Value for "$argumentKey" is null in call of translate(\'$key\')');
      value = '';
    }
    translation = translation.replaceAll("\$$argumentKey", value);
  });
  return translation;
}

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:

child: Center(
  child: Text(
    AppLocalizations.of(context).translate(
      'string',
      {'name': 'Test'}
    ),
    style: TextStyle(
      fontSize: 32
    ),
  )
)

We get this result:

Main screen with localized dynamic string
Main screen with localized dynamic string

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.

class AppLocalizations {
  AppLocalizations(this.locale);

  static AppLocalizations of(BuildContext context) {
    return Localizations.of<AppLocalizations>(context, AppLocalizations);
  }

  static const LocalizationsDelegate<AppLocalizations> delegate =
  _AppLocalizationsDelegate();

  final Locale locale;
  Map<String, String> _localizedStrings;

  Future<void> load() async {
    String jsonString = await rootBundle.loadString('assets/translations/${locale.languageCode}.json');

    Map<String, dynamic> jsonMap = json.decode(jsonString);

    _localizedStrings = jsonMap.map((key, value) {
      return MapEntry(key, value.toString());
    });

    return null;
  }

  String translate(String key) {
    return _localizedStrings[key];
  }
}

class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
  const _AppLocalizationsDelegate();

  @override
  bool isSupported(Locale locale) {
    return true;
  }

  @override
  Future<AppLocalizations> load(Locale locale) async {
    AppLocalizations localizations = new AppLocalizations(locale);
    await localizations.load();
    return localizations;
  }

  @override
  bool shouldReload(_AppLocalizationsDelegate old) => false;
}
class AppLocalizations {
  AppLocalizations(this.locale);
  final Locale fallbackLocale = Locale('en');

  static AppLocalizations of(BuildContext context) {
    return Localizations.of<AppLocalizations>(context, AppLocalizations);
  }

  static const LocalizationsDelegate<AppLocalizations> delegate = _AppLocalizationsDelegate();

  final Locale locale;
  Map<String, String> _localizedStrings;
  Map<String, String> _fallbackLocalizedStrings;

  Future<void> load() async {
    _localizedStrings = await _loadLocalizedStrings(locale);
    _fallbackLocalizedStrings = {};

    if (locale != fallbackLocale) {
      _fallbackLocalizedStrings = await _loadLocalizedStrings(fallbackLocale);
    }
  }

  Future<Map<String, String>> _loadLocalizedStrings(Locale localeToBeLoaded) async {
    String jsonString;
    Map<String, String> localizedStrings = {};

    try {
      jsonString = await rootBundle.loadString('assets/translations/${localeToBeLoaded.languageCode}.json');
    } catch (exception) {
      print(exception);
      return localizedStrings;
    }

    Map<String, dynamic> jsonMap = json.decode(jsonString);

    localizedStrings = jsonMap.map((key, value) {
      return MapEntry(key, value.toString());
    });

    return localizedStrings;
  }

  String translate(String key, [Map<String, String> arguments]) {
    String translation = _localizedStrings[key];
    translation = translation ?? _fallbackLocalizedStrings[key];
    translation = translation ?? "";

    if (arguments == null || arguments.length == 0) {
      return translation;
    }

    arguments.forEach((argumentKey, value) {
      if (value == null) {
        print('Value for "$argumentKey" is null in call of translate(\'$key\')');
        value = '';
      }
      translation = translation.replaceAll("\$$argumentKey", value);
    });

    return translation;
  }
}

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 the fallbackLocale
  • Now we need to fill _fallbackLocalizedStrings. We let that happen in the load 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 the translate 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:

static AppLocalizations instance;

AppLocalizations._init(Locale locale) {
  instance = this;
  this.locale = locale;
 }

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.

@override
Future<AppLocalizations> load(Locale locale) async {
  AppLocalizations localizations = AppLocalizations._init(locale);
  await localizations.load();
  return localizations;
}

Now we can call the localization without a context:

Text(
  AppLocalizations.instance.translate(
    'string_with_interpolation',
    {'name': 'Test'}
  )
);

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:

If you like what you’ve read, feel free to support me:

🥗Buy me a salad

Leave a Comment