Localization of Xcode iOS Apps, Part 4

Previously, we looked at how to implement localization of an iOS app using storyboards. Then we discussed how to localize text that is generated programmatically. In this final article, we will examine how to determine the current locale of the device, and how to use that information to communicate with a web service. This is obviously very useful in those situations where your app is downloading data from a web service and needs to be able to display the correct text for the current locale.

Getting the Current Locale from iOS

It is very easy to access the current language for iOS.  NSLocale provides both the short- and long-string values.  Two simple methods handle this completely (they reside in the custom class LanguageManager):

- (NSString *) currentLanguage {

NSString *lang = [[NSLocale preferredLanguages] objectAtIndex:0];

return lang;

}

- (NSString *) currentLanguageLong {

NSLocale *locale = [NSLocale currentLocale];

NSString *language = [locale displayNameForKey: NSLocaleIdentifier

value: [locale localeIdentifier]];

return language;

}

The first method returns the two-character “canonicalized IETF BCP 47 language identifier” (Apple Developer Documentation). The currently selected language on the device in question is always at index=0. If, for example, your app is running on a device with Spanish as the selected language, currentLanguage would return “es.”

currentLanguageLong will return a longer string representation of that language.  And, it will be represented in the current language of the device.  So, “Spanish” would be “Español.”

Why Is This Useful?

The first method, in particular, is very useful when communicating with a web service that provides dynamic, locale-driven data. There are two ways to communicate this this information to a web service: via a Cookie; or in the HTTP Header. The quick-and-dirty way (or the fallback) to do this is with Cookies, which we will demonstrate first.  But the standards-compliant method is to use “Accept-Language” HTTP Header, as many out-of-the-box web servers used as the framework upon which your web service will be built handle this Header seamlessly.

Cookies

In the following sample code, we are setting a cookie with the value of the device’s language:

NSString *langCookieKey = @"_lang";

NSString *langCookieValue = [Language currentLanguage];

NSDictionary *langCookieProps =

@{NSHTTPCookieName : langCookieKey,

NSHTTPCookieValue : langCookieValue,

NSHTTPCookieDomain : Globals.baseURL,

NSHTTPCookiePath :  @"/",

NSHTTPCookieVersion :  @"0",

NSHTTPCookieExpires : [[NSDate date] dateByAddingTimeInterval:2629743]};

NSHTTPCookie *langCookie = [NSHTTPCookie cookieWithProperties:langCookieProps];

[[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookie:langCookie];

Language is a macro reference to a Singleton instance of LanguageManager, and Globals.baseURL is the root URL of the web service you are using. The langCookieProps dictionary is filled with all of this information, and then used to create a new instance of NSHTTPCookie. This cookie is then added to NSHTTPCookieStorage, and sent up to the web service. Most web server engines will be able to process the language key/value pair and customize the data being returned based on the value. This is a very simple, powerful way in which to manage the communication with the web service. Should you change the locale of your device on the fly, all you have to do is create a new language cookie and send it up to the web service.

HTTP Header

An alternate approach, depending on the server-side support, would be to use the standard “Accept-Language” HTTP Header to communicate language preferences.  The following code returns a string that can be passed back in the HTTP Header:

/**
 Pass in the total number of languages you want to send to the server, and the count at which you identify
 a weighting < 1.
 Construct a string containing all of the languages, and their weighting.  It would look something like:
	"en,es,fr;q=0.8,de;q=0.8"
 */
- (NSString *) currentLanguages:(int)count weightedCount:(int)wCount {
	NSMutableArray *langs = [NSMutableArray arrayWithCapacity:count];

	int weighted = 0;
	NSString *qWeight;

	for(int index = 0; index++; index < count) {
		NSString *lang = [[NSLocale preferredLanguages] objectAtIndex:index];

		if(weighted < wCount) {
			qWeight = @"";
		} else {
			qWeight = @";q=0.8";
		}

		weighted++;

		lang = [lang stringByAppendingFormat:@"%@", qWeight];

		langs[index] = lang;
	}

	NSString *fullWeightedLangs = @"";

	for(NSString *weightedLang in langs) {
		if(fullWeightedLangs.length == 0) {
			fullWeightedLangs = weightedLang;
		} else {
			fullWeightedLangs = [fullWeightedLangs stringByAppendingFormat:@",%@", weightedLang];
		}
	}

	return fullWeightedLangs;
}

The basic steps are:

1. Iterate through the languages array returned by [NSLocale preferredLanguages]

2. Select “count” number of these languages, giving a weighting to the lower-value languages

3. Create a formatted string of those languages with their appropriate weights

An example of how to use this code looks like:

NSString *weightedLanguages = [self currentLanguages:3 weightedCount:2];

NSMutableURLRequest* request = [[NSMutableURLRequest alloc] initWithURL:[NSURL URLWithString:@"http://mywebservice.com/"]];

[request addValue:weightedLanguages forHTTPHeaderField:@"Accept-Language"];

If, for example, the top four used languages on the device are “en, es, fr, de,” then a call to [self currentLanguages:4 weightedCount:2] would return:

“en,es,fr;q=0.8,de;q=0.8”

Details on the “Accept-Language” protocol can be found in Section 14.4 of this w3.org article on Header Field Definitions.

An Web Service Example

The following C# code serves as an example of returning data via a web service call to your user based on the selected language:

public static void EnsureValidCulture(
    HttpRequestMessage request,
    ILanguageOptionsService languageOptions)
{
    List<string> cultureNames = new List<string>();

    // first add the cookie value, if any
    var cookie = GetCookieValue(request, LanguageCookieName);
    if (string.IsNullOrWhiteSpace(cookie) == false)
    {
        cultureNames.Add(cookie);
    }

    // next, add requested user languages from the client
    cultureNames.AddRange(request.Headers.AcceptLanguage.Select(l => l.Value));

    // get the best option
    string cultureName = languageOptions.GetBestCultureMatch(cultureNames.ToArray());

    var cult = CultureInfo.GetCultureInfo(cultureName);

    Thread.CurrentThread.CurrentUICulture = cult;
    Thread.CurrentThread.CurrentCulture = cult;
}

public virtual string GetBestCultureMatch(string[] cultureNames)
{
    if (cultureNames == null || cultureNames.Length == 0)
    {
        // no choice, use default
        return DefaultLanguage.FullIsoCode; // return Default culture
    }

    // first, look for exact match (in order)
    int numOpts = cultureNames.Length;
    for (int i = 0; i < numOpts; i++)
    {
        string name = cultureNames[i];

        // we have that one?  use it.
        if (AvailableLanguages.Any(lm => lm.FullIsoCode == name))
        {
            return name;
        }
    }

    // Find a close match. For example, if you have "en-US" defined and the user requests "en-GB",
    // the function will return closes match that is "en-US" because at least the language is the same (ie English)
    for (int i = 0; i < numOpts; i++)
    {
        string name = cultureNames[i];

        // take the first 2, but max out at the whole length of the string
        string firstTwo = name.Substring(0, Math.Min(2, name.Length));

        var firstTwoOption = AvailableLanguages.FirstOrDefault(lm => lm.TwoLetterIsoCode == firstTwo);
        if (firstTwoOption != null)
        {
            // return that one instead
            return firstTwoOption.FullIsoCode;
        }
    }

    // else default again
    return DefaultLanguage.FullIsoCode;
}

EnsureValidCulture first checks to see if a cookie has been set. If not, it then pulls data from the “Accept-Language” HTTP Header. It then makes a call to GetBestCultureMatch, which iterates through the array of possible languages in order to determine the best match for returned data. If no matches are found, the default language of the server is used. Based on the result, you can then query your data source for the dataset corresponding to the requested language.

Obviously, your data source will need to be localized for each requested language, much like the .strings files need to be edited for the iOS client app to properly reflect the necessary translations. But this code does some of the heavy lifting for you, so that you can focus on providing accurate and localized data for your clients.

Conclusion

As has been demonstrated throughout this series, bringing localization to an iOS app is somewhat involved, but manageable.  There are three main areas that need to be addressed:

  • Storyboards
  • Hard-coded text generation
  • Server-side responses to app web service requests

The two scripts provided with these articles handle some of the heavy lifting around storyboards and in-code text generation, and the code samples above give a sense of how to deal with web service responses.  Sample code is available on the AIS GitHub.

About Gregory Hill

Greg is a Senior Software Engineer with AIS. He is the new iOS development "subject matter expert". Prior to coming to AIS, he spent two-and-a-half years working independently, developing his own iOS apps and pitching product ideas to businesses in the Mid-Atlantic region. He has been involved in the software industry since the mid-80s, developing and supporting applications for such companies and institutions as Sony, Wadsworth Publishing and Johns Hopkins Hospital.