How to Internationalize Your Flutter App | The Phrase Blog
source link: https://phrase.com/blog/posts/how-to-internationalize-a-flutter-app/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Here’s a simplified diagram of our app’s widget tree:
App └── ContactsProvider └── MaterialApp └── {Routes} ├── HomeScreen │ └── Scaffold │ ├── AppBar │ ├── ListView │ │ └── ContactListItem │ └── FloatingActionButton └── AddContactScreen └── Scaffold ├── AppBar └── Form ├── WordFormField └── DatePickerField
You can grab the code for the starter app at its GitHub repo here.
Once you clone the repo, and assuming you have Flutter installed, a quick flutter packages get
on the command line from the project root directory should have you all set. You should be able to run the app on an Android simulator or iOS emulator after that. Let’s go over the business logic of our starter app.
Our main model is a simple Contact
list.
lib/src/models/contact.dart
class Contact { final String name; final DateTime contactNextAt; Contact({this.name, this.contactNextAt}); }
A Contact
represents each row in our main list. The contactNextAt
is supposedly when we would like to get a reminder about contacting a person. For brevity, we don’t have a reminder functionality in the current version of our app.
We export a List<Contact>
to our app’s widgets through an InheritedWidget
.
lib/src/shared_state/contacts_provider.dart
import 'package:flutter/material.dart'; import '../models/contact.dart'; class ContactsProvider extends InheritedWidget { final List<Contact> contacts = [ Contact(name: 'Adam Doe', contactNextAt: DateTime(2018, 10, 1)), Contact(name: 'Sally Doe', contactNextAt: DateTime(2018, 10, 12)), ]; ContactsProvider({Key key, Widget child}) : super(key: key, child: child); static List<Contact> of(BuildContext context) { final provider = (context.inheritFromWidgetOfExactType(ContactsProvider) as ContactsProvider); return provider.contacts; } @override bool updateShouldNotify(ContactsProvider old) => old.contacts != this.contacts; }
Our InheritedWidget
subclass, ContactsProvider
, exposes our list of contacts to the concerned widgets. Let’s see how this is wired up in our app before we look a bit more closely at InheritedWidget
s.
lib/src/app.dart
import 'package:flutter/material.dart'; import 'screens/add_contact_screen.dart'; import 'screens/home_screen.dart'; import 'shared_state/contacts_provider.dart'; class App extends StatelessWidget { static const String title = 'Stay in Touch (i18n Demo)'; @override Widget build(BuildContext context) { return ContactsProvider( child: MaterialApp( title: title, theme: ThemeData( primarySwatch: Colors.blue, ), onGenerateRoute: _getCurrentRoute, ), ); } MaterialPageRoute _getCurrentRoute(RouteSettings settings) { switch (settings.name) { case '/add': return MaterialPageRoute( builder: (context) => AddContactScreen(contacts: ContactsProvider.of(context)), ); default: return MaterialPageRoute( builder: (context) => HomeScreen( title: title, contacts: ContactsProvider.of(context), ), ); } } }
Since our app is small and we want our List<Contact>
to be available to its both screens, we wrap our entire MaterialApp
with our ContactsProvider
. But what exactly is this doing and what is an InheritedWidget
? Our internationalization code will make use of the inherited pattern, so let’s dive into that a bit.
Shared State & InheritedWidget
If you’ve ever used a reactive, component-based framework like Flutter or React, you know that, inevitably, you have pieces of state that you want to share across different components or widgets. This can mean passing these bits of state down widget sub-trees in your app, which can quickly become a maintenance headache. If you want to change the shape or interface of your shared state, you may have to update several pieces of your app. Another problem is that you may need a bit of shared state several levels down a widget sub-tree. To do so, you may have to pass that state down each widget in the sub-tree until you get to the one concerned with the respective state. These in-between widgets shouldn’t need to know about the shared state, since they do nothing with it. This can create confusing widget APIs, where a widget’s parameters don’t directly reflect its needs.
To deal with this, Flutter provides InheritedWidget
s. An InheritedWidget
carries a state that can be shared with a specific widget sub-tree in your app, and Flutter is designed so, that a widget can ask for an InheritedWidget
that was provided to its sub-tree. Flutter will use the InheritedWidget.updateShouldNotify
method to inform a concerned widget whether it should rebuild as a reaction to a change in the information held by the InheritedWidget
.
In our app’s case, we return true
from updateShouldNotify
only when our contact list changes. We also provide our shared state to our entire app, since our app is quite small and only has the contact list to share. However, to keep a bigger app efficient, we would want to be more granular when we share our InheritedWidgets
.
Flutter makes use of the inherited pattern in its i18n, and we’ll see that in action, when we begin internationalizing our app, which incidentally is what we’ll be doing next.
Flutter i18n: Internationalizing the App
Installing our i18n Packages
The first step to internationalizing our app is to add three packages.
flutter_localizations
is included with Flutter and contains several localizations for Flutter’s own widgets (a full list of the available localizations can be found in the Flutter documentation).intl
, an official Dart package, provides many of the i18n and l10n capabilities we need. It supports working with translation messages among other i18n-related things.intl_translation
, another package provided by the Dart team, provides command-line tools for generating code and translation files which we’ll use to localize our app.
We can add these three files to our pubspec.yaml
file.
dependencies: flutter: sdk: flutter flutter_localizations: sdk: flutter # ... intl: ^0.15.7 intl_translation: ^0.17.0 # ...
Running flutter packages get
should install the packages and have us good to go.
Updating Info.plist for iOS
We’ll be working mostly right in Dart and Flutter here. However, iOS won’t see our supported locales unless we explicitly set them in our Info.plist file.
ios/Runner/Info.plist
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>CFBundleDevelopmentRegion</key> <string>en</string> <key>CFBundleLocalizations</key> <array> <string>en</string> <string>ar</string> </array> <!-- ... --> </dict> </plist>
Since we’re supporting English and Arabic, we set their ISO 639-1 codes in the file.
Creating the Localizations and LocalizationsDelegate Classes
Next, let’s create a localizations class that we can provide to our app. Later, this class will include our internationalized messages.
lib/src/lang/sit_localizations.dart
import 'dart:async'; import 'package:intl/intl.dart'; import 'package:flutter/material.dart'; // We have to build this file before we uncomment the next import line, // and we'll get to that shortly // import '../../l10n/messages_all.dart'; class SitLocalizations { /// Initialize localization systems and messages static Future<SitLocalizations> load(Locale locale) async { // If we're given "en_US", we'll use it as-is. If we're // given "en", we extract it and use it. final String localeName = locale.countryCode == null || locale.countryCode.isEmpty ? locale.languageCode : locale.toString(); // We make sure the locale name is in the right format e.g. // converting "en-US" to "en_US". final String canonicalLocaleName = Intl.canonicalizedLocale(localeName); // Load localized messages for the current locale. // await initializeMessages(canonicalLocaleName); // We'll uncomment the above line after we've built our messages file // Force the locale in Intl. Intl.defaultLocale = canonicalLocaleName; return SitLocalizations(); } /// Retrieve localization resources for the widget tree /// corresponding to the given `context` static SitLocalizations of(BuildContext context) => Localizations.of<SitLocalizations>(context, SitLocalizations); }
Our class is called SitLocalizations
to differentiate it from Flutter’s own Localizations
class. “Sit” is just a namespace and it stands for “Stay in Touch”, which is our demo app’s name.
The SitLocalizations.of
method takes a BuildContext
and returns an instance of SitLocalizations
, much like an InheritedWidget
would. We’ll use this method in our widgets to retrieve our translated messages.
Note » We need to comment out our import of the
messages_all.dart
file and itsinitializeMessages()
function call until we generate the file throughintl_translations
. We’ll do that a bit later and come back to un-comment these lines.
This class is pretty useless without a LocalizationDelegate
, which we’ll pass to our app. Let’s take a look at the delegate before we wire it up to our app.
lib/src/lang/sit_localizations_delegate.dart
import 'dart:async'; import 'package:flutter/material.dart'; import 'sit_localizations.dart'; class SitLocalizationsDelegate extends LocalizationsDelegate<SitLocalizations> { const SitLocalizationsDelegate(); @override bool isSupported(Locale locale) => ['ar', 'en'].contains(locale.languageCode); @override Future<SitLocalizations> load(Locale locale) => SitLocalizations.load(locale); @override bool shouldReload(LocalizationsDelegate<SitLocalizations> old) => false; }
Our SitLocalizationsDelegate
class derives from Flutter’s LocalizationDelegate
, and its job is to provide basic localization functions to our app. The isSupported
method returns true
if a given locale is supported by our app. load
will get the current locale’s messages ready for usage. Our delegate’s load
method itself delegates to our SitLocalizations.load
method, which extracts the given locale’s ISO code, loads the locale’s translated messages and forces the Intl
package to use the locale. shouldReload
is similar to InheritedWidget.updateShouldNotify
, and should return true
when our localizations change. Since our app’s localizations won’t change after they’ve initially loaded, we always return false
from shouldReload
.
Wiring Our LocalizationDelegate Up to Our App
Okay, with that in place, let’s go to our app.dart
file and connect our SitLocalizationDelegate
to our app. We’ll also bring in Flutter’s built-in localizations and tell our app which locales we support while we’re at it.
lib/src/app.dart
// ... import 'package:flutter_localizations/flutter_localizations.dart'; import 'lang/sit_localizations.dart'; import 'lang/sit_localizations_delegate.dart'; // ... class App extends StatelessWidget { @override Widget build(BuildContext context) { return ContactsProvider( child: MaterialApp( // ... localizationsDelegates: [ const SitLocalizationsDelegate(), GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, ], supportedLocales: [ // The order of this list matters. By default, if the // device's locale doesn't exactly match a locale in // supportedLocales then the first locale in // supportedLocales with a matching // Locale.languageCode is used. If that fails then the // first locale in supportedLocales is used. const Locale('en'), const Locale('ar'), ], // ...
Note » The
supportedLocales
List will be Flutter’s source of truth for supported locales. So if a user switches his or her device’s language, our app will only follow suit if that language is in our ‘supportedLocales’ list.
The great thing about using Flutter is that it has a lot of built-in niceties. One of them is first-class support for i18n and l10n. The provided MaterialApp
widget’s constructor takes two params relevant to our solution: localizationDelegates
and supportedLocales
. These are where we register our delegates and our app’s supported locales, respectively. Notice that we added GlobalMaterialLocalizations.delegate
and GlobalWidgetsLocalizations.delegate
to our localizationDelegates
. These provide Flutter’s built-in localizations for its own widgets, and we’ll see that in action a bit later. Before those, we provide an instance of our own SitLocalizationsDelegate
, and that wires us all up to use our translations. Speaking of which…
The Flutter l10n Workflow
We’ve wired up our Flutter localization delegate, which will provide our SitLocalizations
to our app’s widgets. However, we haven’t really used SitLocalizations
to provide any localizations. Let’s do that. Our app’s title is a good place to start. Let’s add it as a localized message.
lib/src/lang/sit_localizations.dart
class SitLocalizations { // ... // Localized Messages String get title => Intl.message( 'Stay in Touch (i18n Demo)', name: 'title', desc: 'App title', ) }
We can add the message at the bottom of our SitLocalizations
class. We use the intl package’s Intl.message
method to specify the default string, name
, and desc
ription of the message. The latter is for the benefit of translators, but the message’s name
is required by default by intl.
Generating the Messages ARB file
The intl_translation package provides a command line tool to create an ARB (application resource bundle) file. We can run it from our command line, in our project’s root directory, to generate the file.
flutter pub pub run intl_translation:extract_to_arb --output-dir=lib/l10n lib/src/lang/sit_localizations.dart
Note » The command won’t create the lib/l10n directory by itself, and will squawk if it doesn’t find the directory. So make sure to create the directory manually before running the command.
This will generate a lib/l10n/intl_messages.arb file for us. If you open the file, you should see something like the following:
{ "@@last_modified": "2018-09-26T19:32:39.619278", "title": "Stay in Touch (i18n Demo)", "@title": { "description": "App title", "type": "text", "placeholders": {} } }
Let’s make a copy of this file for each locale we support. Our app supports English and Arabic, so we can create two copies and name them intl_messages_ar.arb and intl_messages_en.arb. Both files can live in the lib/l10n directory, just like the original ARB file. Now let’s open the English intl_message_en.arb file and add its locale key. When we’re done, it should look like the following:
lib/l10n/intl_messages_en.arb
{ "@@locale": "en", "@@last_modified": "2018-09-26T19:32:39.619278", "title": "Stay in Touch (i18n Demo)", "@title": { "description": "App title", "type": "text", "placeholders": {} } }
Similarly, let’s open the Arabic version and edit it, specifying the locale key and adding our translated version of the string.
lib/l10n/intl_messages_ar.arb
{ "@@locale": "ar", "@@last_modified": "2018-09-26T19:32:39.619278", "title": "إبقى على تواصل - عرض ترجمة", "@title": { "description": "App title", "type": "text", "placeholders": {} } }
Generating the Dart Message Files from the ARB Files
We need a second step to have our messages ready to use by our app. The intl_translation package provides a command that generates Dart code files from our ARB files. We can run it like this:
flutter pub pub run intl_translation:generate_from_arb lib/src/lang/sit_localizations.dart lib/l10n/*.arb --output-dir=lib/l10n
This will generate four additional files in our lib/l10n directory:
- messages_all.dart
- messages_ar.dart
- messages_en.dart
- messages_messages.dart
We don’t really need to be too concerned with what these files do, since intl and intl_packages are responsible for them, given that we’ve created our ARB files correctly. For the curious ones, however, suffice it to say that these files help to load our localized messages and provide them as a Dart code to our app.
Wiring Up Our Messages Dart File to our Localizations Class
We can now return our SitLocalizations
class and un-comment the lines we had commented out before.
lib/src/lang/sit_localizations.dart
// ... import '../../l10n/messages_all.dart'; class SitLocalizations { /// Initialize localization systems and messages static Future<SitLocalizations> load(Locale locale) async { // ... // Load localized messages for the current locale. await initializeMessages(canonicalLocaleName); // ...
Okay, we’re ready to bring in our first translation.
Accessing our Flutter Localization Data
Let’s return to our main App
widget and swap out our hard-coded title
for the localized one.
lib/src/app.dart
// ... class App extends StatelessWidget { @override Widget build(BuildContext context) { return ContactsProvider( child: MaterialApp( onGenerateTitle: (context) => SitLocalizations.of(context).title, // ... onGenerateRoute: _getCurrentRoute, ), ); } MaterialPageRoute _getCurrentRoute(RouteSettings settings) { // ... return MaterialPageRoute( builder: (context) => HomeScreen( title: SitLocalizations.of(context).title, contacts: ContactsProvider.of(context), ), ); } } }
Notice that we can’t use the title
param in our MaterialApp
‘s constructor anymore. That’s because we won’t have access to our SitLocalizations
before the MaterialApp
is constructed itself. To deal with that, MaterialApp
provides the onGenerateTitle
param, which accepts a function that is passed a BuildContext
that we can use to access our SitLocalizations
. We pull our title
message out of the SitLocalizations
, and do so again in our home route to pass the title to the HomeScreen
.
Now if we go into our device’s settings and switch the language to Arabic, we should see our Arabic title in the home screen when we return to our app.
Also, notice that the app’s direction is right-to-left in Arabic. That is so sweet! Flutter’s built-in support for Arabic and locale direction can save us a lot of time when internationalizing.
Learn how to find the best i18n manager and follow our best practices for making your business a global success.
Check out the guideHandling Interpolation & Plurals
Our home screen’s app bar has a counter that displays the number of contacts in our list. Let’s internationalize this counter. We’ll have to pass the count in as a param to our message, and we’ll have to handle pluralization. Arabic can be a bit tricky with plurals since it has different plural forms for zero, one, two, three to ten, and eleven and up. Luckily, the intl package can handle all this. Let’s add a plural message to our SitLocalizations
class.
bin/src/lang/sit_localizations.dart
class SitLocalizations { // ... String contactCount(int howMany) => Intl.plural( howMany, zero: 'No contacts', one: '$howMany contact', two: '$howMany contacts', few: '$howMany contacts', many: '$howMany contacts', other: '$howMany contacts', args: [howMany], name: 'contactCount', desc: 'Contact counter', ); }
The Intl.plural
takes the count param, called howMany
, as its first param. Note that when using interpolation with Intl
messages, we have to provide the arguments we’re accepting as a list to the args
param. Just like before, the name
param is required for ARB file generation. You can skip the desc
param if you want to, although I’m choosing to keep it here.
The pluralization params, zero
, one
, two
, etc. are optional, with the exception of the other
param. This gives us great flexibility when dealing with plurals. For example, if we were dealing only with languages that had three plural forms, like English, we could have built our message as follows:
String contactCount(int howMany) => Intl.plural( howMany, zero: 'No contacts', one: '$howMany contact', other: '$howMany contacts', args: [howMany], name: 'contactCount', desc: 'Contact counter', );
Now let’s run the command to regenerate our ARB file.
flutter pub pub run intl_translation:extract_to_arb --output-dir=lib/l10n lib/src/lang/sit_localizations.dart
This will update our lib/l10n/intl_messages.arb file to look like this:
{ "@@last_modified": "2018-09-27T16:51:21.790906", "title": "Stay in Touch (i18n Demo)", "@title": { "description": "App title", "type": "text", "placeholders": {} }, "contactCount": "{howMany,plural, =0{No contacts}=1{{howMany} contact}=2{{howMany} contacts}few{{howMany} contacts}many{{howMany} contacts}other{{howMany} contacts}}", "@contactCount": { "description": "Contact counter", "type": "text", "placeholders": { "howMany": {} } } }
We can copy our new contactCount
key-value pairs to our intl_messages_en.arb and intl_messages_ar.arb files. Our Arabic version will need to be translated and can look like this when we’re done with it:
lib/l10n/intl_messages_ar.arb
{ "@@locale": "ar", "@@last_modified": "2018-09-26T19:32:39.619278", "title": "إبقى على تواصل - عرض ترجمة", "@title": { "description": "App title", "type": "text", "placeholders": {} }, "contactCount": "{howMany,plural, =0{لا توجد أطراف}=1{طرف {howMany}}=2{طرفان}few{{howMany} أطراف}many{{howMany} طرف}other{{howMany} طرف}}", "@contactCount": { "description": "Contact counter", "type": "text", "placeholders": { "howMany": {} } } }
We can now re-run our command to generate the Dart code from our ARB files.
flutter pub pub run intl_translation:generate_from_arb lib/src/lang/sit_localizations.dart lib/l10n/*.arb --output-dir=lib/l10n
Let’s bring it all together by updating our HomeScreen
to make use of our new message.
lib/src/screens/home_screen.dart
// ... import '../lang/sit_localizations.dart'; // ... class HomeScreen extends StatelessWidget { // ... @override Widget build(BuildContext context) { final sortedContacts = List<Contact>.from(contacts) ..sort((a, b) => a.contactNextAt.compareTo(b.contactNextAt)); final l10n = SitLocalizations.of(context); return Scaffold( appBar: AppBar( title: Text(title), centerTitle: false, elevation: 0.0, actions: <Widget>[ Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Text(l10n.contactCount(sortedContacts.length)), ), ), ], ), // ...
After importing our sit_localizations.dart file, we can access our current localizations through the handy SitLocalizations.of
method. This just returns an instance of SitLocalizations
, so we can store it in a variable, l10n
. We then use this object to access our new message, passing it the current number of contacts so that it can do its magic.
If we restart the app, we should now see this:
Working With Dates
Our app is starting to look 🌍-friendly, but we still have that “Contact next YYYY/MM/DD” string in each contact row. Let’s get that string i18n-ized, shall we? You’ll know the drill by now. We start by adding a new message to our SitLocalizations
class.
lib/src/lang/sit_localizations.dart
// ... class SitLocalizations { // ... String contactNextAtDate(String date) => Intl.message( 'Contact next at $date', name: 'contactNextAtDate', args: [date], desc: 'Indicator for when to make next contact with person', ); }
Just like before, we do our ARB generation, copy the message to each locale’s ARB file, and generate the Dart files. After that, we can incorporate the message in our ContactListItem
widget.
lib/src/widgets/contact_list_item.dart
// ... import '../lang/sit_localizations.dart'; // ... class ContactListItem extends StatelessWidget { // ... String get _formattedDate { final date = contact.contactNextAt; return '${date.year}/${date.month}/${date.day}'; } @override Widget build(BuildContext context) { final l10n = SitLocalizations.of(context); return Column( key: Key(contact.name), children: <Widget>[ ListTile( title: Text(contact.name), subtitle: Text(l10n.contactNextAtDate(_formattedDate)), ), // ...
After we make that swap, the Arabic version of our app should look like the following.
Notice that while the alphabetic part is translated now, we’re still seeing the English date form. We can correct this by using the intl package’s date formatting functions. First, we need to initialize date formatting in our locale loading method.
lib/src/lang/sit_localizations.dart
// ... import 'package:intl/date_symbol_data_local.dart'; // ... class SitLocalizations { /// Initialize localization systems and messages static Future<SitLocalizations> load(Locale locale) async { // ... // Date formatting is loaded on an as-needed basis. Since // we need it for our app, we load it here. await initializeDateFormatting(canonicalLocaleName); return SitLocalizations(); } // ...
date_symbol_data_local.dart is imported from the intl package, and its initialzeDateFormatting()
function is called in our SitLocalizations.load
method.
Note » At time of writing, date formatting seemed to work fine for me even if I didn’t call
initialzeDateFormatting()
. However, the official intl documentation states that the function should be called at least once before any of the package’s date formatting methods are called. I suppose it’s safe enough to make the call, so I’ve left it in here.
Now let’s update our ContactListItem
to use intl’s date formatting.
lib/src/widgets/contact_list_item.dart
import 'package:intl/intl.dart'; // ... class ContactListItem extends StatelessWidget { // ... String get _formattedDate => DateFormat('d/M/y').format(contact.contactNextAt); // ...
intl’s DateFormat
class takes a format string and returns a formatter function. We can pass our Date
object to this formatter function to get a formatted date in the current locale.
Et voilà!
Note » intl’s documentation covers all the format characters that
DateFormat()
accepts.
Using Flutter’s Localized Widgets
When we add a new contact in our AddContactScreen
, we use Flutter’s own showDatePicker
function behind the scenes.
We didn’t have to localize the date picker widget ourselves. The showDatePicker
function is part of Flutter’s material library, and the Flutter team has localized it in Arabic and English for us. Several other localizations come out of the box with Flutter, which can be a huge time saver.
I won’t bore you with internationalizing the rest of the app, since it’s basically “rinse and repeat” from here. However, if you want to see the fully internationalized and localized app, check out the Git repo for the completed project.
Do More with intl
The intl library can do more than what we covered here. It can help you work with numbers, genders, bidirectional text, and money. Check out the intl documentation to see how you can save time in your i18n work by using the package.
And Now I Flutter Away (Sorry!)
Writing code to localize your app is one task, but working with translations is a completely different story. Many translations for multiple languages may quickly overwhelm you which will lead to the user’s confusion. Fortunately, Phrase can make your life as a developer easier!
Now with built-in ARB file support (Flutter devs rejoice!), Phrase is your one stop shop i18n/l10n platform. Built by developers for developers, Phrase features a robust API and CLI. Phrase also offers native integration with GitHub, GitLab, and Bitbucket. Branching, versioning, over-the-air (OTA) translations, machine learning translations, and a beautiful web console for your translators are just some of the perks Phrase gives to your development team. Check out all of Phrase’s features and take it for a free spin for 14 days.
I hope this article helped you get started with Flutter i18n and l10n. If you have any questions or would like to see different Flutter or non-Flutter i18n topics covered, please let us know in the comments. Happy Coding 🙂
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK