Internationalization - Make an Flutter application multi-lingual

forward from: https://www.didierboelens.com/2018/04/internationalization---make-an-flutter-application-multi-lingual/

This article explains a way to make your Flutter application multilingual and to allow the user to select another working language, other than the one, defined at the Smartphone’s settings.

Forewords

Internationalization has already been explained several times and the Flutter official documentation on this topic may be found here.

As I wanted to understand it correctly but also, because I needed to extend it to meet my application’s requirements, I decided to write the following article to share my experience and give you some hints.

Requirements

  • By default, the working language should be the one configured in the Smartphone (system settings)
  • If the default language is not supported by the application, ‘en’ becomes the default
  • Translations are stored in language specific JSON files, as assets
  • The user may select another working language, from a list of supported languages
  • When the user selects another language, the whole application layout is refreshed in order to be displayed in the newly selected language

External dependencies

Flutter natively supports localization (notion of Locale). The Locale class is used to identity the user’s language. One of the properties of this class defines the languageCode.

To use the localization package, you will need to use the flutter_localizations package. To do so, you will have to add it as a dependency to your pubspec.yaml file as follows:

dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
1
2
3
4
5

When saving the pubspec.yaml, you will be prompted to load/import the dependencies: accept and load/import them.

Translation files

The next step is to create the translations files and define them as assets.

From my past experience developing websites, I used to store these assets in a folder called “/locale”, under the naming convention: i18n_{lang}.json. I will use the same principle in this article.

Therefore, create a new folder at the same level as the /lib and call it “/locale”. In this folder, create 2 files, respectively named: i18n_en.json and i18n_fr.json.

The folders tree should then look like the following:

MyApplication | +- android +- build +- images +- ios +- lib +- locale | +- i18n_en.json +- i18n_fr.json +- test

Now, we need to make these 2 files, part of the assets.

In order to do this, you will have to edit the pubspec.yaml file and add then both to the assets: section, as follows:

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

flutter:
  assets:
    - locale/i18n_en.json
    - locale/i18n_fr.json
1
2
3
4
5
6
7
8
9
10

Implementation

It is now time to start with the implementation…

Let’s start at the beginning, the application’s main initialization: main.dart

import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'translations.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'My Application',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      localizationsDelegates: [
        const TranslationsDelegate(),
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
      ],
      supportedLocales: [
          const Locale('en', ''),
          const Locale('fr', ''),
      ],
      home: new Scaffold(
          appBar: new AppBar(
            title: new Text('My Title'),
          ),
          body: new Container(
          ),
      ),        
    );
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
  • Line #2: simply imports the flutter localizations package
  • Line #3: imports the personal library that handles the translations (see later)
  • Lines #16-20: 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
    • const TranslationsDelegate() points to the personal library to handle the translations (see later)
  • Lines #21-24: list of the supported languages

Let’s now have a look at the personal library that handles the translations: translations.dart.

Under /lib, create a new file, named “translations.dart”.

The initial content of the translations.dart file is:

import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show  rootBundle;

class Translations {
  Translations(Locale locale) {
    this.locale = locale;
    _localizedValues = null;
  }

  Locale locale;
  static Map<dynamic, dynamic> _localizedValues;

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

  String text(String key) {
    return _localizedValues[key] ?? '** $key not found';
  }

  static Future<Translations> load(Locale locale) async {
    Translations translations = new Translations(locale);
    String jsonContent = await rootBundle.loadString("locale/i18n_${locale.languageCode}.json");
    _localizedValues = json.decode(jsonContent);
    return translations;
  }

  get currentLanguage => locale.languageCode;
}

class TranslationsDelegate extends LocalizationsDelegate<Translations> {
  const TranslationsDelegate();

  @override
  bool isSupported(Locale locale) => ['en','fr'].contains(locale.languageCode);

  @override
  Future<Translations> load(Locale locale) => Translations.load(locale);

  @override
  bool shouldReload(TranslationsDelegate old) => false;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
  • Lines #33-44: definition of the personal localization delegate. Its role is to instantiate our personal Translations class (line 40), proceeding with validation of supported languages (line 37).
  • Lines #6-31: definition of the personal Translations class.

Explanation of the Translations class

The main objectives of this class are:

  • when instantiated by the TranslationsDelegate class, the class receives the Locale to be used for the translations. At first instantiation, the Locale which is passed as argument to the constructor of the class corresponds to the Smartphone Locale as defined in the Settings.
  • the constructor simply memorizes this Locale and resets the Map that is going to hold the translations in that Locale.languageCode
  • the load method when called by the TranslationsDelegate class, initializes a new instance of the Translations class, loads the content of the i18n_{language}.json file and converts the JSON to a Map. It then returns the new instance of the class.
  • method Translations of(BuildContext context) is used to return a pointer to this instance of the class. This is will used when we will need to get the translations of a certain string/label (see later)
  • text(String key) will be used to return the translation of a certain string/label, based on the content of the Map (see later).
  • currentLanguage is a getter to return the language, currently being used.

How to structure the i18n_{language}.json files?

These files are JSON files, which have to stick to the JSON syntax rules.

These files will contain a series of key/value pairs, as in the following example (i18n_en.json):

{
    "app_title": "My Application Title",
    "main_title": "My Main Title"
}
1
2
3
4

Warning!: unlike Dart/Flutter good practice, JSON syntax does not accept trailing comma’s!

It is now up to you to fill both i18n_en.json and i18n_fr.json files with the key/value pairs.

For this example, the counter part in French would be (i18n_fr.json):

{
    "app_title": "Le titre de mon application",
    "main_title": "Mon titre principal"
}
1
2
3
4

How to get the translations?

To get the translation of a certain label/string, pass the context and the label you want to be translated as follows:

@override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: new Text(Translations.of(context).text('main_title')),
    ...
1
2
3
4
5
6

How to dynamically change the working language?

OK, now that we know how to define the languages, the translations and use the translations, how can we let the user change the working language?

In order to do this, we need to way to force the refresh of the whole application layout when the user selects another working language. Therefore, we need to application to handle a setState().

Before going any further, let’s refactor the code and let’s create a new file, called application.dart.

This file will be used for 2 purposes:

  • repository of application settings
  • share globals
typedef void LocaleChangeCallback(Locale locale);
class APPLIC {
    // List of supported languages
    final List<String> supportedLanguages = ['en','fr'];

    // Returns the list of supported Locales
    Iterable<Locale> supportedLocales() => supportedLanguages.map<Locale>((lang) => new Locale(lang, ''));

    // Function to be invoked when changing the working language
    LocaleChangeCallback onLocaleChanged;

    ///
    /// Internals
    ///
    static final APPLIC _applic = new APPLIC._internal();

    factory APPLIC(){
        return _applic;
    }

    APPLIC._internal();
}

APPLIC applic = new APPLIC();

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

This is a self-initializing class. Each time it will be imported in the source code, the very same instance of the class will be returned.

Let’s now make the application “refreshable”…

import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'translations.dart';
import 'application.dart';

void main() => runApp(new MyApp());

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => new _MyAppState();
}

class _MyAppState extends State<MyApp> {
  SpecificLocalizationDelegate _localeOverrideDelegate;

  @override
  void initState(){
    super.initState();
    _localeOverrideDelegate = new SpecificLocalizationDelegate(null);
    ///
    /// Let's save a pointer to this method, should the user wants to change its language
    /// We would then call: applic.onLocaleChanged(new Locale('en',''));
    /// 
    applic.onLocaleChanged = onLocaleChange;
  }

  onLocaleChange(Locale locale){
    setState((){
      _localeOverrideDelegate = new SpecificLocalizationDelegate(locale);
    });
  }

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'My Application',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      localizationsDelegates: [
        _localeOverrideDelegate,
        const TranslationsDelegate(),
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
      ],
      supportedLocales: applic.supportedLocales(),
      home: new MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  _MyHomePageState createState() => new _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage>{
  @override
  Widget build(BuildContext context){
    return new Scaffold(
      appBar: new AppBar(
        title: new Text(Translations.of(context).text('main_title')),
      ),
      body: new Container(),
    );
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
  • Lines #8-13: The first modification to apply is to make the application Stateful. This will allow us to respond to a request for refresh and invoke the application SetState().
  • Line #19: we need to instantiate a new Localization Delegate, which will be used to force a new instantiation of the Translations class when the user will select another working language.
  • Line #24: we save in the Global APPLIC class a pointer to a method which will be used to force a full application refresh, via the SetState()
  • Lines #27-31: the heart of the application refresh when we change the language. Each time another language is selected, a new instance of the SpecificLocalizationDelegate is created, which, we will see just after, will force a refresh of the Translations class.
  • Line #42: we register the new delegate
  • Line #47: refactoring. Now that we have a Global APPLIC class to hold the settings, let’s use it.
  • Line #62: let’s take the opportunity to use the translations library.

Now we need to apply some changes to the Translations.dart file… (final version)

import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show  rootBundle;
import 'application.dart';

class Translations {
  Translations(Locale locale) {
    this.locale = locale;
    _localizedValues = null;
  }

  Locale locale;
  static Map<dynamic, dynamic> _localizedValues;

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

  String text(String key) {
    return _localizedValues[key] ?? '** $key not found';
  }

  static Future<Translations> load(Locale locale) async {
    Translations translations = new Translations(locale);
    String jsonContent = await rootBundle.loadString("locale/i18n_${locale.languageCode}.json");
    _localizedValues = json.decode(jsonContent);
    return translations;
  }

  get currentLanguage => locale.languageCode;
}

class TranslationsDelegate extends LocalizationsDelegate<Translations> {
  const TranslationsDelegate();

  @override
  bool isSupported(Locale locale) => applic.supportedLanguages.contains(locale.languageCode);

  @override
  Future<Translations> load(Locale locale) => Translations.load(locale);

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

class SpecificLocalizationDelegate extends LocalizationsDelegate<Translations> {
  final Locale overriddenLocale;

  const SpecificLocalizationDelegate(this.overriddenLocale);

  @override
  bool isSupported(Locale locale) => overriddenLocale != null;

  @override
  Future<Translations> load(Locale locale) => Translations.load(overriddenLocale);

  @override
  bool shouldReload(LocalizationsDelegate<Translations> old) => true;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
  • Line #38: refactoring in order not to hardcode the supported languages
  • Lines #47-60: the implementation of the Delegate, which forces a new instantiation of the Translations class each time a new language is selected.

How to apply a change of working language? Here is the cherry on the cake!

To force another working language, one single line of code is necessary, anywhere in the source code of the application:

applic.onLocaleChanged(new Locale('fr',''));

1
2

Thanks to the Global applic instance, we can make a call to the Main application method onLocaleChange(Locale locale), which will create a new instance of the Delegate, which in turn will force a new instance of the Translations class, with the new language.

The whole application will then be refreshed.

Conclusions

I am sure other solutions exist, maybe with better code.

The sole objective of this article is to share a solution that works for me, which I hope could also help others.

In a next article, I will detail a dedicated Widget to select working languages, which will complement this article.

Stay tuned and happy coding.