OneSheep

Localizing Xamarin.Forms apps when using a shared project

This winter we built a cross platform app for Langham Preaching using Xamarin Forms. Forms allowed us to be quite productive and we pushed out a long backlog of stories simultaneously on both Android and iOS platforms. This was possible partly because we could share more than 90% of our code between the two platforms by using two shared projects:

Langham.Model – all the data entities and persistence with unit tests covering the business logic.
Langham – all the UI and presentation

Langham targets this app for use by it’s members who live in 22 different countries world wide. Most of those users would not be preparing their sermons in English and so it was no surprise to see a localization story in the product backlog.

The official guide to do this is Localizing Xamarin.Forms Apps with RESX Resource Files. However, near the top of the guide we find this warning:

Shared Projects are not recommended
The remainder of this document relates to projects using the Xamarin.Forms PCL template.

We didn’t want to change our shared projects into portable class libraries, because we never need to share this code with any other solutions and we don’t need the risk of picking a PCL profile that will later prove to be limiting. Also .NET Standard did not look quite ready for primetime. So, we had to roll our own localization mechanism.

Merging namespaces

It made most sense to embed the language resource files in the shared UI project. The difficulty with this was, that we had to flatten the namespace so the .Net resource manager could resolve the assembly reference. Our UI project had many sub namespaces like Langham.Sermons, Langham.Clubs, Langham.Content etc. We did not need to touch these. But we did have to flatten Langham.iOS and Langham.Droid to be just one namespace with Langham. Since more than 90% of our code was in the shared projects it was just renaming one or two classes and changing the namespace declaration on less than a dozen files and we were good to go.

Locale service

We start off our localization with a simple interface which exposes just enough functionality to serve our business layer and unit tests:

namespace Langham.Model
{
   public interface ILocale
   {
       string Native(string key);
       string NativeOrKey(string key);
   }
}

The real work is done in an abstract class in the UI project where we have this:

namespace Langham
{
    
    public abstract class Locale : Langham.Model.ILocale
    {
        protected const string DEFAULT_LANGUAGE = "en";

        public CultureInfo CI { get; }

        public Locale()
        {
            CI = GetCurrentCultureInfo();
        }

        public string NativeOrKey(string key)
        {
            string translated = Fetch(key);

            if (translated == null)
                return key;

            return translated;
        }

        public string Native(string key)
        {
            string translated = Fetch(key);

            if (translated == null)
                throw new ArgumentException($"Key {key} was not found in resources", key);

            return translated;
        }

        string Fetch(string key)
        {
            var manager = new ResourceManager("Langham.Resx.AppResources", typeof(Locale).GetTypeInfo().Assembly);
            return manager.GetString(key, CI);            
        }

        public abstract void ApplyLocale();

        protected abstract string GetNetLanguage();

        protected abstract string GetNetFallbackLanguage(string languageCode);

        protected CultureInfo GetCurrentCultureInfo()
        {
            var netLanguage = GetNetLanguage();

            CultureInfo ci = null;

            try
            {
                ci = new CultureInfo(netLanguage);
            }
            catch (CultureNotFoundException)
            {
                // platform locale not valid .NET culture (eg. "en-ES" : English in Spain on iOS)
                // fallback to first characters, in this case "en"
                try
                {
                    var fallback = GetNetFallbackLanguage(LanguageCode(netLanguage));
                    Debug.WriteLine($"{netLanguage} failed, trying {fallback}");
                    ci = new CultureInfo(fallback);
                }
                catch (CultureNotFoundException)
                {
                    // fallback not valid, falling back to default language
                    Debug.WriteLine($"{netLanguage} couldn't be set, using '{DEFAULT_LANGUAGE}'");
                    ci = new CultureInfo(DEFAULT_LANGUAGE);
                }
            }

            return ci;
        }


        private string LanguageCode(string platformCultureString)
        {
            var platformString = platformCultureString.Replace("_", "-"); // .NET expects dash, not underscore
            var dashIndex = platformString.IndexOf("-", StringComparison.Ordinal);

            if (dashIndex == -1) return platformString;

            var parts = platformString.Split('-');
            return parts[0];
        }
    }
}

Google and Apple have their own conventions of naming locales and these are standardised to the Microsoft way by platform-specific implementations of the two abstract methods. The CultureInfo value is cached because it is called a lot and expensive to calculate.

Take a look at this article’s gist for details of the two platform specific abstractions for iOS and Android.

Now all that is left to do is to register the service with our service container in the bootup code. For example in our AppDelegate.cs file for the iOS project we use TinyIoC like this:

public override bool FinishedLaunching(UIApplication app, NSDictionary options)
{
    ...
    var container = TinyIoCContainer.Current;
    var locale = new iOSLocale();
    container.Register(locale);
    ...
}

Usage in code

To use the mechanism to get a local translation of a literal string, we can do something like this in our c# view models:

var lang = Container.Current.Resolve<Locale>();

// was: feedbackText = "You entered the wrong email address. Please try again.";
feedback.Text = lang.Native("WrongEmailEntered");

Usage in layouts

But most of our literal strings were in XAML files, so to localize those we use this XAML markup extension.

Now we can do this:

<Label Text="{local:Translate Place}"  />  <!-- was: <Label Text="Place"  /> -->

Just remember to reference your local namespace in the XAML root tag:

  
<ContentPage 
        xmlns="http://xamarin.com/schemas/2014/forms" 
        xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
        xmlns:local="clr-namespace:Langham"
        > 

Posted on May 18, 2017 by Jannie Theunissen

Back to all posts