Localization System for iOS

Localization System for iOS

To increase the reach of your application, you should translate and localize it for the languages and regions where your target audience is. Apple has a default mechanism in place with NSLocalizedString to translate strings and genstrings to extract string from your code. The default Apple mechanism is based on the localization (language and region) settings of the operating system. This means that within your application you don't need to worry about creating a language selection screen, or changing the language.

This way of working is somewhat limited. If you support only a couple of languages, and your users are not in those languages, they are going to see the default - or canonical - language, which is usually english.

In Belgium, where iCapps is located, we have three official languages. Dutch, French and German. So for some of our customers, we need to localize our apps in those languages. Sometimes we also need to be able to change the language selection, independent of the operating system. For example if the users device is not in one of the supported languages. There the default Apple Localization system reaches its limits.

There are actually a couple of options you can consider if you need to support this scenario. You could put a custom system in place, where you use a couple of plist files and a class to get strings and other resources in the current language. The problem here is that the default user interface elements that get shown, for example when taking a picture of just the back button in a navigation controller, will remain in the language of the operating system.

The solution we came up with, is very closely related to the default Apple localization architecture, but with the extra features we need. We can still use genstrings to extract strings from source. The system is so similar to the default, that developers immediately understand how to use it. We can change the language of our application on the fly. The default user interface elements also get displayed in the language the user expects.

Let's see how it works:

We have a single class that exposes the API to get the tranlated strings and other resources. It implements the singleton pattern.

+ (LocalizationSystem *)shared;

// gets the string localized
- (NSString *)localizedStringForKey:(NSString *)key
                              value:(NSString *)value
                              table:(NSString *)tableName;

// gets instance of viewcontroller with localized nib.
- (id) localizedViewController: (Class) vcClass;

// gets instance of localized image 
- (UIImage *) localizedImage:(NSString *)name;

// sets the language
- (void) setLanguage:(NSString*) language;
- (NSString *) language;

// resets this system.
- (void) resetLocalization;

@end

To make it easier to use that class, we have defined a couple of macros.

#define ICLocalizedString(key, comment) 
[[LocalizationSystem shared] localizedStringForKey:(key) value:@"" table:nil]
#define ICLocalizedStringFromTable(key, tbl, comment) 
[[LocalizationSystem shared] localizedStringForKey:(key) value:@"" table:(tbl)]

#define ICLocalizedStringWithDefaultValue(key, tbl, val, comment) 
[[LocalizationSystem shared] localizedStringForKey:(key) value:(val) table:(tbl)]

#define ICLocalizedViewController(class) 
[[LocalizationSystem shared] localizedViewController:(class)]

#define ICLocalizedImage(name) 
[[LocalizationSystem shared] localizedImage:(name)]

#define ICLocalizationSetLanguage(language) 
[[LocalizationSystem shared] setLanguage:(language)]

#define ICLocalizationReset 
[[LocalizationSystem shared] resetLocalization]

Let's have a look at the implementation:

First we have an extension where we have a property to hold all available localized bundles, and implement the singleton pattern:

 

#import "LocalizationSystem.h"

@interface LocalizationSystem ()

@property (nonatomic, strong) NSArray /*  */ *bundles;

@end

@implementation LocalizationSystem

@synthesize bundles = _bundles;

+ (LocalizationSystem *)shared
{
    __strong static LocalizationSystem *_sharedLocalSystem = nil;

    static dispatch_once_t pred = 0;
    dispatch_once(&pred, ^{
        _sharedLocalSystem = [[self alloc] initPrivate];
    });
    return _sharedLocalSystem;

}

The initialization takes care of setting up the default language, or the previously set language using UserDefaults.

- (id) initPrivate
{
    self = [super init];
    if(self)
    {
        NSString *language = [[NSUserDefaults standardUserDefaults] objectForKey:@"ICPreferredLanguage"];
        if ( language && ![language isEqualToString:@""] )
        {
            [self setLanguage:language];
        }
        else {
            [self resetLocalization]; // use default bundle
        }
    }
    return self;
}

- (id)init
{
    @throw @"Please use the singleton.";
}

Let's have a look at the language property. This is where the magic actually happens:

// Sets the desired language of the ones you have.
// LocalizationSetLanguage(@"en");
// LocalizationSetLanguage(@"nl");
// LocalizationSetLanguage(@"fr");
- (void) setLanguage:(NSString*) language
{
    NSMutableArray *appleLangs = [NSMutableArray arrayWithArray:[[NSUserDefaults standardUserDefaults] objectForKey:@"AppleLanguages"]];

    [appleLangs removeObject:language];
    [appleLangs insertObject:language atIndex:0];

    [[NSUserDefaults standardUserDefaults] setObject:appleLangs forKey:@"AppleLanguages"];
    [[NSUserDefaults standardUserDefaults] synchronize]; // needs a restart.

    [[NSUserDefaults standardUserDefaults] setObject:language forKey:@"ICPreferredLanguage"];
    [[NSUserDefaults standardUserDefaults] synchronize];

    NSMutableArray *languages = [[NSMutableArray alloc]initWithCapacity:3];

	NSString *path = [[NSBundle mainBundle]
                      pathForResource:language ofType:@"lproj" ];
    if (nil != path)
    {
        [languages addObject:[NSBundle bundleWithPath:path]];
    }

	if ([language rangeOfString:@"-"].location != NSNotFound) // try the neutral culture bundle
    {
		language = [[language componentsSeparatedByString:@"-"]objectAtIndex:0];
        // fallback
        path = [[NSBundle mainBundle]
                pathForResource:language ofType:@"lproj" ];
        if (nil != path)
        {
            [languages addObject:[NSBundle bundleWithPath:path]];
        }
    }

    [languages addObject:[NSBundle mainBundle]];
    self.bundles = languages;
}

- (NSString *) language
{
    NSString *lang = [[NSUserDefaults standardUserDefaults]
                      objectForKey:@"ICPreferredLanguage"];
    if( nil == lang)
    {
        NSMutableArray *appleLangs = [NSMutableArray arrayWithArray:[[NSUserDefaults standardUserDefaults] objectForKey:@"AppleLanguages"]];

        lang = [appleLangs objectAtIndex:0];
    }

    return lang;
}

The key action we take in setLanguage is reordering the languages in a special setting that is kept per application: the list of languages: AppleLanguages. We also store the language in the UserDefaults under the ICPreferredLanguage key, so that the next time the app starts, the LocalizationSystem is initialized correctly.

Then we find the localized bundles, within the application resources, and store them in an array for later use. We try to find the specific bundle, this means the bundle in the language that was set, taking into account the region: eg. nl-BE.lproj, next we try to find the culture-neutral bundle that corresponds to it: eg. nl.lproj and last we have the invariant (or canonical) culture.

We also have a getter, that returns the language that is currently used by the LocalizationSystem. You won't often need it.

We support reset:

// Resets the localization system, so it uses the OS default language.
- (void) resetLocalization
{
	self.bundles = [[NSArray alloc]initWithObjects:[NSBundle mainBundle], nil];
}

Now the methods to actually get the translated resources:

// Gets the current localized string as in NSLocalizedString.
- (NSString *)localizedStringForKey:(NSString *)key value:(NSString *)value table:(NSString *)tableName;
{
    NSString *msg = nil;
    for( NSBundle *bundle in self.bundles )
    {
        msg = [bundle localizedStringForKey:key value:value table:tableName];

        if( ![msg isEqualToString:key] && ![msg isEqualToString:value])
        {
            return msg;
        }

    }
    return msg;
}

They are all alike: they will look for the localized resource by key in order of most specific, to less specific. So if a key is not defined in nl-BE, it will look for that key in nl, and last in en.

The same for localized controller and localized image:

- (id) localizedViewController: (Class) vcClass
{
    NSString *xibName = NSStringFromClass(vcClass);
    for( NSBundle *bundle in self.bundles )
    {
        if(nil != [bundle pathForResource:xibName ofType:@"nib"])
        {
            return [[vcClass alloc]initWithNibName:xibName bundle:bundle];
        }
    }
    return [[vcClass alloc]init];
}

- (UIImage *)localizedImage:(NSString *)name
{
    NSString *pathExtension = [name pathExtension];
    if(!pathExtension || [pathExtension isEqualToString:@""]) {
        pathExtension = @"png"; // png is default :-)
    }
    NSString *fileName = [name stringByDeletingPathExtension];

    for( NSBundle *bundle in self.bundles )
    {
        NSString *path = [bundle pathForResource:fileName ofType:pathExtension];

        if(nil != path)
        {
            return [[UIImage alloc]initWithContentsOfFile:path];
        }
    }
    return nil;
}

That's it. I've created a demo application, so you can check it out in real life. Find it on Github.

There is on caveat: For the change in language to take effect in the default UI elements, the application needs to be restarted. This is how we do that usually:

You show an alertView, to indicate to the user that you will exit the application. When the user confirms, you schedule a local notification to be shown when the application is no longer running, so that the user gets a popup or notification, so that she can easily restart it.

Class cls = NSClassFromString(@"UILocalNotification");
if (cls) {
    UILocalNotification *notif = [[UILocalNotification alloc] init];
    notif.fireDate = [NSDate dateWithTimeIntervalSinceNow:1];
    notif.timeZone = [NSTimeZone defaultTimeZone];

    notif.alertBody = ICLocalizedString(@"The language of your app has been changed.", @"");
    notif.hasAction = YES;
    notif.alertAction = ICLocalizedString(@"Restart", @"");

    [[UIApplication sharedApplication] scheduleLocalNotification:notif];
}
[self performSelector:@selector(shutdown) withObject:nil afterDelay:0.2];

- (void) shutdown
{
    exit(0);
}

Thanks for reading,
Please leave your questions and comments below.

Jeroen