Internationalization of a Cross Platform Application
- Posted in:
- .net
- mvvm
- windows phone
- windows rt
The use of PCL projects and paradigms such as MVVM allow for a great code sharing between all the different platform projects. The recently announced shared projects in Visual Studio 2013 Update 2 and the support of shared projects by Xamarin improve the code sharing when developing for multiple platforms. I’ve been using Xamarin tools for Visual Studio to create cross platform applications for mobile devices, but this is not enough. If you truly want to use MVVM, data binding, etc. then you need to resort to complementing frameworks. I’ve been using MvvmCross, as it is one of the most complete and mostly adopted frameworks for using MVVM in non-windows platform developments (such as in iOS and Android) with Xamarin.
MvvmCross is an excellent framework and library. It provides nuget packages for the core and a set of plugins for platform specific functionality. It’s very easy to adopt and understand, thanks to the great tutorials published by its author, Stuart Lodge, in its N+1 series. In all, is a must if you want to create cross platform applications. However, there is one thing I don’t like about MvvmCross: internationalization support.
Internationalization of applications using MvvmCross (explained in this N+1 series video) in my opinion has the following shortcomings:
- Looks like a proprietary (non-standard) way of implementing internationalization
- It is based on platform specific plugins
- Localization resources are defined in the platform projects and not in the PCL project. This means that you will have to repeat the strings and localization on each platform.
- It is based on JSON (not that I don’t like JSON, but I don’t like it when used in something that it is not standard)
- There are no tools that will allow to easily translate the strings
Stuart, at the end of the video, suggests that there might be other ways to do internationalization, by using resource files. Resource files are supported by Visual Studio and also by Xamarin. However, resource files are not supported in the platform specifics. At the end of the video Stuart points at this url for an alternative approach to internationalization using resource files. After reading the article I was not satisfied, the article was not clear and I had a difficult time following it. I thought that there must be a better approach to it.
I wanted a solution to internationalization that complies with the following guidelines:
- It must be based on resource files
- Resource files should be maintained in only one project
- I should be able to use the excellent Multilingual App Toolkit tool to maintain the translation
- It should use the platform specific way of implementing internationalization
Finding a solution to comply with all of the above guidelines was not easy, so I wanted to share the findings so other people will save time digging in the internet for a better solution to internationalization.
I will use a solution such as the one used in the article A sample cross platform application using MVVM, but you can use any existing solution that has a PCL project and platform specific projects for WinRT, Windows Phone, iOS and/or Android.
Using the Multilingual App Toolkit in a PCL project
I found this article on the internet that explains how to use the Multilingual App Toolkit in a PCL project. The article explained what I wanted to accomplish, but it didn’t explain all that needs to be done in order to make it work. It lacks some important steps to actually have it all working. The article also shows part of the whole solution: it only explains the internationalization for WinRT and Windows Phone platform and doesn’t explain how to extend this to iOS and Android projects. I suggest you read the article since it provides a good explanation of the difference of resource files between PCL, WinRT and Windows Phone. Following is a more detailed explanation of how the use the Multilingual App Toolkit in a PCL project
I’m assuming that you have already installed the Multilingual App Toolkit extension in your Visual Studio. If you haven’t please do install it before continuing.
We will use the Windows Phone project as the main project for our internationalization work. The reason is because Windows Phone and PCL projects use the same .resx files for resources, and also because it is much easier to use the Multilingual App Toolkit from a Windows Phone project.
When you create a Windows Phone project, the Visual Studio template includes a Resources\AppResources.resx resource file. We will add a copy of this resource file in the PCL project since this will need the resource file as part of its file structure. We will then remove the Windows Phone resource file and add a link to the resource file defined in the PCL.
Follow these steps:
- In the PCL project, create a Resources folder
- Add a copy of the AppResources.resx file located in the Windows Phone Resources folder
- Make sure that the custom tool used for this added file is PublicResXFileCodeGenerator, and that the resource file has also a designer file associated with it.
- Delete the AppResources.resx file from the Resources folder of the Windows Phone project
- In the Windows Phone project, in the Resources folder, add a link to the AppResources.resx file of the PCL project
- Make sure that the custom tool used for this linked file is empty
- In the Windows Phone project, delete the file called LocalizedStrings.cs
Your solution structure should now look as this:
Now, follow these steps:
- With the Windows Phone project selected, in Visual Studio menu go to “Tools” and then select “Enable Multilingual App Toolkit”. This will add the pseudo language to the project (which you can delete if you want to).
- Right click on the Windows Phone project and from the menu select the “Add Translation Language…" option. This will open the Multilingual App Toolkit dialog to add languages to your project.
- Add as many languages as you need. In my case, and for this example, I’m adding Spanish and Italian (no culture specific)
- In the PCL project, select the option to “Show All Files” on the Solution Explorer
- In the PCL project, refresh the Resources folder. You should see a .resx file for each of the added languages.
- Select all then .resx files and then right click and choose “Include in Project”. This will add those resources files to the PCL project.
Your solution structure should now look as this:
Now, we need to add a service to our PCL project to provide translated strings. Follow these steps:
- In the PCL project, open the default resource file, Resources\AppResources.resx, and change the access modifier from “Internal” to “Public”
- In the Services folder, add a new class called ResourceService.cs and replace the code with the following:
using SampleApp.Resources; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace SampleApp.Services { public class ResourceService { public static AppResources Resources; public AppResources LocalizedResources { get { return Resources ?? (Resources = new AppResources()); } } } }
- On the Windows Phone project, open App.xaml and replace the following line:
<local:LocalizedStrings xmlns:local="clr-namespace:SampleApp" x:Key="LocalizedStrings"/>
with this line:
<services:ResourceService x:Key="LocalizedStrings" />
make sure you define the services namespace to point to the PCL assembly.
- On the WinRT project, open App.xaml and add the following resource:
<services:ResourceService x:Key="LocalizedStrings" />
make sure you define the services namespace to point to the PCL assembly.
Now, we need to configure the projects. Follow these steps:
- In the Windows Phone project, right click and choose “Properties” to see the project properties. In the “Application” tab, make sure that the section “Supported Cultures” list all the languages available for the application.
- In the WinRT project, open the manifest file, Package.appxmanifest, and in the “Application” tab make sure that “Default language” is set to the same language used as your default language in the Windows Phone project.
Now you can start using the Multilingual App Toolkit to translate your resources. As an example, let’s create a new string:
- In the Windows Phone project, double click the Resources\AppResources.resx file
- Add a new string (in this example “users”) and its translation to the default language (in this example “en”)
- Compile the solution
- The Multilingual App Toolkit will update the .xlf files, and also update the .resx files, which are located in the PCL project file structure.
- On the Windows Phone project, double click any of the .xlf files to open the Multilingual Editor and start doing the translation of the new strings
At this point we have the following:
- All resource files are in the PCL project
- The main resource file is edited from the Windows Phone project
- We can use the Multilingual App Toolkit, from the Windows Phone project, to edit translations
- We have a service in the PCL project that will provide translated strings for all projects
Let’s see now how can we use this to translate the user interface of a WinRT and a Windows Phone project (iOS and Android will be explained later)
Translation in the PCL
The PCL has no user interface, so we need to worry only about how to get translated strings from code. Resource files automatically generate a strongly typed class with all the resources (see the associated file AppResources.Designer.cs file). We can use this class to get access to the strings:
string label = AppResources.users;
Translation in Windows Phone
Remember, from all the previous steps, that we have a ResourceService class in our PCL project to provide translated strings. We have created a static resource in the App.xaml that creates an instance of this service.
We can get translated strings in XAML by using data binding against the service. Here is an example for the string “users” created before:
<TextBlock Text="{Binding LocalizedResources.users, Source={StaticResource LocalizedStrings}}" />
Notice the use of the static resource defined in App.xaml, which we named LocalizedStrings. From code, we can use the same service, as follows:
label.Text = AppResources.users;
Translation in WinRT
We use a similar approach to the translation in Windows Phone. We have also created the static resource in App.xaml. In XAML you get a translated string as follows:
<TextBlock Text="{Binding LocalizedResources.users, Source={StaticResource LocalizedStrings}}" />
Notice the use of the static resource defined in App.xaml, which we named LocalizedStrings. From code, we can use the same service, as follows:
label.Text = AppResources.users;
There is one drawback to doing translation this way in WinRT: we don’t get to use the x:Uid way of translating UI elements. But this is not a problem since this way only applies to WinRT and we want to do translations once for all platforms.
Translation in Other Platforms
Let’s see how we are on our goals:
- It must be based on resource files: Yes! We use resource files to declare our strings
- Resource files should be maintained in only one project: Yes! We use the Windows Phone project to maintain the resources
- I should be able to use the Multilingual App Toolkit tool to maintain the translation: Yes! We use the Multilingual App Toolkit from the Windows Phone project to translate our strings.
- It should use the platform specific way of implementing internationalization: Yes for Windows Phone and WinRT, no yet for iOS and Android.
iOS and Android do not use resource files, they use their own method of declaring internationalization strings. So far our solutions doesn’t work for these two platforms.
There is a very good article by Chris Miller in Code Magazine called Cross-Platform Localization for Mobile Apps where the author provides a very good explanation of how internationalization should be done in iOS and Android.
Resources in Android
In Android, resources are specified in XML files, and are contained in folder structure inside the Resources folder, where each folder is specified as Values-<languageCode>:
The contents of a resource file is as follows:
Resources in iOS
In iOS, resources are specified as text files, and are contained in a folder structure in the root of the project, where each folder is specified as <languageCode>.lproj:
Each folder has a Localizable.strings file containing the following structure:
A Partial Solution
In the Code Magazine article, the author solves the gap between resource files and the iOS/Android method using a very clever approach based on T4 templates. The author provides the T4 templates in the article, but I thought these could be improved. The ones provided by the article require some manual modifications before you can use them. Also, in the case of iOS you have to use string literals in order to reference the strings, and we don’t like to use string literals, we would rather use an auto-generated class with all the strings as constants. So I think this is a partial solution to the problem.
As I did in the case of the resources files for windows, I thought I could give a better approach to the one provided in Code Magazine. So, here we go.
An Improved T4 Template to Generate iOS/Android String Resources
For this we will need a couple of extensions from the Visual Studio gallery. We need the T4 Toolbox, and optionally if we want to edit the T4 templates, a T4 editor such as the Tangible T4 Editor. Once the extensions are installed, restart Visual Studio and reopen your solution.
We will create the T4 templates in our PCL project. In the PCL project, under the Resources folder, add a new T4 template (select a “Text Template” item from the “Add New Item…” dialog box in VS). Call the template Resx2OthersTemplate.tt, and replace its content with the following:
<#@ include file="T4Toolbox.tt" #> <#@ assembly name="System.Core" #> <#@ assembly name="System.Xml" #> <#@ assembly name="System.Xml.Linq" #> <#@ Assembly Name="System.Windows.Forms" #> <#@ import namespace="System" #> <#@ import namespace="System.IO" #> <#@ import namespace="System.Diagnostics" #> <#@ import namespace="System.Linq" #> <#@ import namespace="System.Collections" #> <#@ import namespace="System.Collections.Generic" #> <#@ import namespace="System.Text.RegularExpressions" #> <#@ import namespace="System.Xml" #> <#@ import namespace="System.Xml.Linq" #> <#@ import namespace="Microsoft.VisualStudio.TextTemplating" #> <#+ public static class ResxUtilities { public static Dictionary<string, string> GenerateResources(string resourceName) { Dictionary<string, string> items = new Dictionary<string, string>(); string locale = GetLocale(resourceName); if (locale != "") { locale = "_" + locale; } // Read in the .resx file and collect the data elements if (File.Exists(resourceName)) { XDocument document = XDocument.Parse(File.ReadAllText(resourceName)); foreach(var item in document.Element("root").Elements("data")) { string Name = EscapeName(item); string Value = EscapeValue(item); items.Add(Name, Value); } } return items; } public static string GetNameSpace(string filename) { string [] words = filename.Replace(".\\", "").Split(new char[] {'.'}); return words[0]; } public static string GetLocale(string filename) { filename = Path.GetFileName(filename); string [] words = filename.Replace(".\\", "").Split(new char[] {'.'}); if (words.Length > 2) { return words[1]; } else { return ""; } } public static string EscapeName(XElement item) { string name = item.Attribute("name").Value; return Regex.Replace(name, "[^a-zA-Z0-9_]{1,1}", "_"); } public static string EscapeValue(XElement item) { XElement vitem = item.Descendants().FirstOrDefault(); string name = vitem.Value; name = name.Replace("'", "\\'"); return name; } public static string GetLanguage(string filename) { string lang = null; var f = Path.GetFileName(filename); var foo = f.Split('.'); if (foo.Count() > 2) { if (foo[1].Length == 2) { lang = foo[1]; } } if (foo.Count() == 2) { lang = "en"; } return lang; } } public class Resx2AndroidTemplate : Template { public string ResxFileName {get; set;} public override string TransformText() { string fullname = this.Context.Host.ResolvePath(ResxFileName); Dictionary<string, string> items = ResxUtilities.GenerateResources(fullname); int l = items.Count; WriteLine("<?xml version=\"1.0\" encoding=\"utf-8\"?>"); WriteLine("<resources>"); foreach(KeyValuePair<string, string> pair in items) { Write(" <string name=\""); Write(pair.Key); Write("\">"); Write(pair.Value); WriteLine("</string>"); } WriteLine("</resources>"); return this.GenerationEnvironment.ToString(); } } public class Resx2iOSTemplate : Template { public string ResxFileName {get; set;} public override string TransformText() { string fullname = this.Context.Host.ResolvePath(ResxFileName); Dictionary<string, string> items = ResxUtilities.GenerateResources(fullname); int l = items.Count; foreach(KeyValuePair<string, string> pair in items) { WriteLine(String.Format("\"{0}\"=\"{1}\";", pair.Key, pair.Value)); } return this.GenerationEnvironment.ToString(); } } public class Resx2ClassTemplate : CSharpTemplate { public string ResxFileName {get; set;} public override string TransformText() { #> namespace <#= DefaultNamespace #> { public static class Strings { <#+ string fullname = this.Context.Host.ResolvePath(ResxFileName); Dictionary<string, string> items = ResxUtilities.GenerateResources(fullname); foreach(KeyValuePair<string, string> pair in items) { #> public static string <#= Identifier(pair.Key) #> = "<#= pair.Key #>"; <#+ } #> } } <#+ return this.GenerationEnvironment.ToString(); } } #>
In the same folder add another T4 template and call it Resx2Others.tt, and replace its content with the following:
<#@ template debug="true" hostSpecific="true" #> <#@ output extension="log" #> <#@ import namespace="System.Text" #> <#@ import namespace="System.IO" #> <#@ assembly name="EnvDTE" #> <#@ include file="Resx2OthersTemplate.tt" #> <# // Create instances of the templates for iOS and Android Resx2AndroidTemplate androidTemplate = null; Resx2iOSTemplate iosTemplate = null; Resx2ClassTemplate classTemplate = new Resx2ClassTemplate(); var hostServiceProvider = (IServiceProvider)Host; var dte = (EnvDTE.DTE)hostServiceProvider.GetService(typeof(EnvDTE.DTE)); foreach(EnvDTE.Project project in dte.Solution.Projects) { string projectName = project.Name.ToLower(); if(projectName.Contains("ios") || projectName.Contains("touch")) { iosTemplate = new Resx2iOSTemplate(); iosTemplate.Output.Project = project.FullName; iosTemplate.Output.ItemType = "Content"; } else if(projectName.Contains("droid")) { androidTemplate = new Resx2AndroidTemplate(); androidTemplate.Output.Project = project.FullName; androidTemplate.Output.ItemType = "AndroidResource"; } } // Set the current directory to the .tt folder Directory.SetCurrentDirectory(Path.GetDirectoryName(Host.TemplateFile)); // Set the file mask for the resx files to read from var files = Directory.GetFiles(".", "AppResources*.resx"); foreach(var resxFile in files) { WriteLine("Processing file {0}", resxFile); // Fix up the file name string resxFileName = resxFile.Replace(".\\",""); string locale = ResxUtilities.GetLocale(resxFile); if (!(locale.Equals("qps-ploc", StringComparison.CurrentCultureIgnoreCase))) { if (!string.IsNullOrWhiteSpace(locale)) { locale = "-" + locale.Replace("-", "-r"); } // Android if(androidTemplate != null) { androidTemplate.ResxFileName = resxFileName; string androidStringsFolder = @"Resources\Values" + locale; // Set the destination filename and path and transform the resource androidTemplate.Output.File = Path.Combine(androidStringsFolder, Path.GetFileName(Path.ChangeExtension(ResxUtilities.GetNameSpace(resxFile), ".xml"))); androidTemplate.Output.Encoding = Encoding.UTF8; androidTemplate.Render(); } // iOS if(iosTemplate != null) { iosTemplate.ResxFileName = resxFileName; // Don't need the locale, just the language var lang = ResxUtilities.GetLanguage(iosTemplate.ResxFileName); if (lang != null) { iosTemplate.Output.File = Path.Combine(lang + ".lproj", "Localizable.strings"); iosTemplate.Output.Encoding = Encoding.UTF8; iosTemplate.Render(); } } // generate a class file with constants only for the main resource file (which doesn't specify a locale) if(String.IsNullOrWhiteSpace(locale)) { classTemplate.Output.File = "Strings.cs"; classTemplate.ResxFileName = resxFileName; classTemplate.Render(); } } } #>
This is the main T4 template, the one that needs to run. Notice that this template includes the Resx2OthersTemplate.tt template. These templates are just a rewrite of the templates found in the Code Magazine article, so the credit should go to Chris Miller for coming out with this solution. All I did was to improve the idea, adding some automations to remove constants and also to create a strong typed class with all the string names to be used in code. This is a summary of the improvements I made:
- The template automatically detects if a iOS and/or Android project is part of the solution. If an iOS/Android is not part of the solution then it skips the creation of strings for that platform.
- The template automatically finds out the name of the projects, so there is no need to specify these in the template
- The template automatically generates a Strings.cs file in the PCL project providing constants to be used instead of string literals in the code.
- The template sets the appropriate content type for iOS to “Content” and for Android to “AndroidResource”
- The code was changed to be more modular
T4 templates do not run automatically on build (unless you have the pro version of the Tangible T4 editor). There are some solutions in the internet to have T4 templates run on build, but I will not mention them here since this article is already too long. For now, remember to run the T4 template (right click on Resx2Others.tt and choose from the menu the option “Run Custom Tool”) every time you modify your translations. Of course you don’t need to do this on every build, I guess that the way you translate an app is to start with one (e.g. Windows Phone) and once you have all the app translated, you use the Multilingual App Toolkit to translate strings, and then you run the T4 template to update the translation strings for both iOS and Android.
Translation in Android
When you run the T4 template you will automatically generate the Values-<languageCode> folders and the AppResources.xml files with all the strings. To translate elements of the UI you will use the following:
<TextView android:text="@string/users" android:layout_width="fill_parent" android:layout_height="wrap_content" android:id="@+id/textView1" />
In code you will use the following:
label.Text = Resources.GetString(Resource.String.users);
Translation in iOS
When you run the T4 template you will automatically generate the <languageCode>.lproj folders and the Localizable.strings files with all the strings. To translate in code you will use:
label.Text = NSBundle.MainBundle.LocalizedString(Strings.users, Strings.users);
Notice that we are using a class named Strings instead of the string literals. This class was generated as part of the T4 template and is included in the PCL project. This class is generated as follows:
public static class Strings { public static string ResourceFlowDirection = "ResourceFlowDirection"; public static string ResourceLanguage = "ResourceLanguage"; public static string ApplicationTitle = "ApplicationTitle"; public static string AppBarButtonText = "AppBarButtonText"; public static string AppBarMenuItemText = "AppBarMenuItemText"; public static string users = "users"; }
With this auto-generated class we avoid the use of string literals in our projects.
Sample App
You can find a sample VS project here:
Conclusion
Let’s review again our goals:
- It must be based on resource files: Yes! We use resource files to declare our strings
- Resource files should be maintained in only one project: Yes! We use the Windows Phone project to maintain the resources
- I should be able to use the Multilingual App Toolkit tool to maintain the translation: Yes! We use the Multilingual App Toolkit from the Windows Phone project to translate our strings.
- It should use the platform specific way of implementing internationalization: Yes! for Windows Phone, WinRT, iOS and Android.
I think that this approach is a much better one than the one used in MvvmCross. Let me know what you think, leave a comment!