Cet article explique une manière de rendre votre application Flutter multilingue et de permettre à l’utilisateur de sélectionner une autre langue de travail, autre que celle définie dans les paramètres du Smartphone.

Difficulté: Intermédiaire

Solution complète disponible (28/03/2019)

Suite à de nombreuses questions, j’ai décidé de fournir une solution complète dans la section FAQ.
Suivez ce lien pour accéder directement à la solution.


Changement important (19/10/2018)

Certains d’entre vous ont éprouvé quelques difficultés pour implémenter la solution initiale, présentée dans la première version de cet article.


Dans la plupart des cas, ces problèmes étaient liés à la notion de contexte et le fait qu’ils ne pouvaient retrouver l’instance de l’objet Translations.

J’ai dès lors décidé de complémenter cet article et de fournir une deuxième solution qui devrait résoudre les problèmes rencontrés précédemment.

L’article initial reste valide et permet de comprendre la notion d’internationalisation mais la seconde version est très certainement beaucoup plus facile à implémenter.

Cliquez ici pour accéder directement à la seconde solution.

Contexte

L’internationalisation a déjà été expliquée plusieurs fois et la documentation officielle de Flutter sur ce sujet peut être trouvée ici.

Comme je voulais comprendre correctement mais aussi parce que j’avais besoin de l’étendre pour répondre aux exigences de mon application, j’ai décidé d’écrire l’article suivant pour partager mon expérience et vous donner quelques conseils.

Besoins

  • Par défaut, la langue de travail doit être celle configurée dans le Smartphone (paramètres du système)
  • Si la langue par défaut n’est pas supportée par l’application, ‘en’ devient la langue par défaut
  • Les traductions sont stockées dans des fichiers JSON (1 par langue), en tant qu’assets
  • L’utilisateur peut sélectionner une autre langue de travail, à partir d’une liste de langues supportées par l’application
  • Lorsque l’utilisateur sélectionne une autre langue, l’application est actualisée afin d’être affichée dans la nouvelle langue sélectionnée

Dépendances externes

Flutter supporte nativement la localisation (notion de Locale). La classe Locale est utilisée pour identifier la langue de l’utilisateur. Une des propriétés de cette classe définit la langue (languageCode).

Pour utiliser le package de localisation, vous devez utiliser le package flutter_localizations. Pour ce faire, vous devrez l’ajouter en tant que dépendance à votre fichier pubspec.yaml comme suit:

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

Lors de la sauvegarde de pubspec.yaml, vous serez invité à charger / importer les dépendances: acceptez et chargez / importez-les.

Fichiers de traductions

L’étape suivante consiste à créer les fichiers de traduction et à les définir comme assets.

De mon expérience de développement de sites Web, je stockais ces ressources dans un dossier appelé “/locale”, sous la convention de nommage: i18n_{lang}.json.

Je vais utiliser le même principe dans cet article.

Par conséquent, créez un nouveau dossier au même niveau que /lib et appelez-le “/locale”. Dans ce dossier, créez 2 fichiers, respectivement nommés: i18n_en.json et i18n_fr.json.

L’arborescence des dossiers devrait alors ressembler à ceci:

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

Maintenant, nous devons faire ces 2 fichiers, des assets.

Pour ce faire, vous devrez éditer le fichier pubspec.yaml et ajouter ensuite les deux références à la section assets:, comme suit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter

flutter:
  assets:
    - locale/i18n_en.json
    - locale/i18n_fr.json

Implémentation

Il est maintenant temps de commencer avec l’implémentation…

Commençons par le début, l’initialisation principale de l’application: main.dart

 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
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.
  @override
  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(
          ),
      ),        
    );
  }
}
  • Ligne #2: importation des libraires Flutter
  • Ligne #3: importation de la librairie personnelle de gestion des traductions (voir plus tard)
  • Lignes #16-20: sont des factory qui produisent des collections de valeurs traduites.
    • GlobalMaterialLocalizations.delegate fournit les traductions qui seront utilisées par les Material Components.
    • GlobalWidgetsLocalizations.delegate définit la direction du texte par défaut, de gauche à droite ou de droite à gauche, pour la bibliothèque de widgets
    • const TranslationsDelegate() pointe vers la bibliothèque personnelle pour gérer les traductions (voir plus loin)
  • Lignes #21-24: liste des langues supportées par l’application

Jetons un coup d’œil à la bibliothèque personnelle qui gère les traductions: translations.dart.

Sous /lib, créez un nouveau fichier, appelé “translations.dart”.

Le contenu initial du fichier translations.dart est:

 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
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;
}

  • Lignes #33-44: définition d’un delegate spécifique. Son rôle est d’instancier notre classe Translations (ligne 40), en procédant à la validation des langues supportés (ligne 37).
  • Lignes #6-31: définition de la classe Translations.

Explications de la classe Translations

Les principaux objectifs de cette classe sont:

  • lorsqu’elle est instanciée par la classe TranslationsDelegate, la classe reçoit le Locale à utiliser pour les traductions. A la première instanciation, le Locale qui est passé en argument au constructeur de la classe correspond au Locale Smartphone tel que défini dans les Paramètres.
  • le constructeur mémorise simplement ce Locale et réinitialise la Map qui va contenir les traductions dans ce Locale.languageCode
  • la méthode load lorsqu’elle est appelée par la classe TranslationsDelegate, initialise une nouvelle instance de la classe Translations, charge le contenu du fichier * i18n_{language}.json* et convertit le JSON en Map. Il retourne ensuite la nouvelle instance de la classe.
  • la méthode Translations of (BuildContext context) est utilisée pour renvoyer un pointeur vers cette instance de la classe. Ceci sera utilisé lorsque nous aurons besoin d’obtenir les traductions d’une certaine chaîne / étiquette (voir plus loin)
  • text(String key) sera utilisé pour renvoyer la traduction d’une certaine chaîne / étiquette, basée sur le contenu de la Map (voir plus loin).
  • currentLanguage est un getter pour retourner la langue, actuellement utilisée.

Comment structurer les fichiers i18n_{language}.json ?

Ces fichiers sont des fichiers de type JSON, qui doivent répondre à la syntaxe JSON.

Ces fichiers contiennent une série de paires key/value, comme dans l’exemple suivant (i18n_en.json):

{
    "app_title": "My Application Title",
    "main_title": "My Main Title"
}

Attention!: contrairement aux bonne pratiques de Dart/Flutter, la syntaxe JSON n’accepte pas les virgules additionnelles!

C’est maintenant à vous de remplir les fichiers i18n_en.json et i18n_fr.json avec les paires key/value.

Pour cet exemple, la partie en français serait (i18n_fr.json):

{
    "app_title": "Le titre de mon application",
    "main_title": "Mon titre principal"
}

Comment obtenir les traductions ?

Pour obtenir la traduction d’une étiquette / chaîne, passez le contexte et l’étiquette que vous voulez traduire comme suit:

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

Comment changer dynamiquement la langue de travail ?

OK, maintenant que nous savons comment définir les langues, les traductions et utiliser les traductions, comment pouvons-nous laisser l’utilisateur changer la langue de travail?

Pour ce faire, nous devons forcer la réactualisation de l’application entière lorsque l’utilisateur sélectionne une autre langue de travail. Par conséquence, nous avons besoin que l’application puisse gérer un setState().

Avant d’aller plus loin, refactorisons le code et créons un nouveau fichier, appelé application.dart.

Ce fichier sera utilisé pour 2 raisons:

  • référentiel des paramètres de l’application
  • partage de données globales
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();

C’est une classe auto-initialisante. A chaque fois que ce fichier sera importé dans le code source, la même instance de la classe sera retournée.

Rendons maintenant l’application actualisable (”refreshable”)…

 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
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(),
    );
  }
}
  • Lignes #8-13: Nous devons d’abord rendre l’application Stateful. Cela nous permettra de répondre à une demande d’actualisation et d’appeler le setState() de l’application.
  • Ligne #19: nous devons instancier un nouveau Localization Delegate, qui sera utilisé pour forcer une nouvelle instanciation de la classe Translations lorsque l’utilisateur sélectionnera une autre langue de travail.
  • Ligne #24: nous sauvegardons un pointeur dans la classe Global APPLIC. Ce dernier pointe vers une méthode qui sera utilisée pour forcer un rafraîchissement total de l’applicaiton, via le SetState()
  • Lignes #27-31: le coeur du rafraîchissement de l’application lorsque l’utilisateur sélectionne une langue. Chaque fois qu’une langue est sélectionnée, une nouvelle instance de SpecificLocalizationDelegate est créée ce qui, nous le verrons juste après, forcera une mise-à-jour de la classe Translations.
  • Ligne #42: nous enregistrons un nouveau delegate.
  • Ligne #47: refactorisation. Maintenant que nous avons une classe globale APPLIC, utilisons-la.
  • Ligne #62: prenons l’opportunité d’utiliser la classe de traductions.

Nous devons appliquer quelques modifications au fichier Translations.dart … (version finale)

 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
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;
}
  • Ligne #38: refactorisation afin de ne pas hard coder les langues supportées par l’application
  • Lignes #47-60: l’implémentation de Delegate, qui force une nouvelle instanciation de la classe Translations chaque fois qu’une nouvelle langue est sélectionnée.

Comment appliquer un changement de langue de travail?

Voici la cerise sur le gâteau!

Pour forcer une autre langue de travail, une seule ligne de code est nécessaire, n’importe où dans le code source de l’application:

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

Grâce à l’instance Globale de applic, nous pouvons appeler la méthode onLocaleChange(Locale locale) de l’application, ce qui créera une nouvelle instance du Delegate, qui à son tour forcera une nouvelle instance de la classe Translations, avec la nouvelle langue.

L’application entière sera ensuite actualisée.


Seconde Solution

Cette section décrit une deuxième solution que j’ai personnellement commencé à utiliser dans mes projets.

Cette solution est basée sur la notion de Singleton et ne repose plus sur la notion de contexte. Cela simplifie beaucoup les choses.

Dépendances

Les dépendances répertoriées dans la section Dépendances externes restent valides.

La nouvelle classe de type Singleton repose également sur une dépendance supplémentaire (package: shared_preferences), qui permet de sauvegarder / récupérer la langue par défaut.

Veuillez ajouter “shared_preferences: ^0.4.3” à la liste des dépendances de vos applications dans le fichier pubspec.yaml.

Nouvelle version du module de traduction

Cette nouvelle version s’appuie sur une nouvelle classe, appelée “GlobalTranslations”, implémentée en tant que singleton et qui n’est donc instanciée qu’une seule fois.

Une fois importée dans votre application, elle expose une variable globale, appelée “allTranslations”.

Le code source de cette classe est le suivant:

Comment utiliser le module ?

Le code suivant illustre une manière d’utiliser le module (les explication seront données à la fin du code source)…

Explications:

  • Ligne 3: importation du module
  • Ligne 7: au démarrage de l’application, nous initialisons le module via un appel à la méthode “init()”. Cette méthode accepte un paramètre facultatif qui vous permet de mentionner la langue à utiliser. Si ce paramètre n’est pas mentionné, le module recherchera toute langue préférée qui aurait pu être enregistrée en tant que préférence partagée. Si aucune ne peut être trouvée, la langue par défaut sera ‘en’.
  • Ligne 25: si votre application doit exécuter une action si la langue de travail est modifiée, vous pouvez définir une fonction callback qui sera appelée. Cela vous permettra de prendre les mesures appropriées.
  • Lignes 42-45: sont des factory qui produisent des collections de valeurs traduites.
    • GlobalMaterialLocalizations.delegate fournit les traductions qui seront utilisées par les Material Components.
    • GlobalWidgetsLocalizations.delegate définit la direction du texte par défaut, de gauche à droite ou de droite à gauche, pour la bibliothèque de widgets
  • Ligne 47: indique à la librairie localization, quelles sont les langues prises en charge. Veuillez adapter le fichier source all_translations.dart, ligne 11, pour spécifier d’autres langues prises en charge.
  • Ligne 62: récupère la langue qui est actuellement utilisée
  • Ligne 66: l’appel à allTranslations.text(…) renvoie le texte demandé dans la langue actuelle
  • Ligne 74: nous appelons allTranslations.setNewLanguage() pour indiquer au système d’utiliser une autre langue et de charger les traductions correspondantes à partir des fichiers JSON.

Conclusions

Je suis certain que d’autres solutions existent, peut-être avec un meilleur code.

Le seul objectif de cet article est de partager une solution qui fonctionne pour moi et qui, je l’espère, pourrait aussi aider d’autres personnes.

Dans un prochain article, je détaillerai un Widget dédié à la sélection des langues de travail, qui complètera cet article.

Restez à l’écoute et bon codage.