The iOS internationalization basics I keep forgetting

The iOS internationalization basics I keep forgetting

In this article, I try to summarize the bare minimum one needs to know to add internationalization support to an iOS app.

Localizations, locales, timezones, date and currency formatting... it's shocking how easy is to forget how they work and how to use them correctly.

After years more than 10 years into iOS development, I decided to write down a few notes on the matter, with the hope that they will come handy again in the future, hopefully not only to me.

TL;DR

From Apple docs:

Date: a specific point in time, independent of any calendar or time zone;

TimeZone: information about standard time conventions associated with a specific geopolitical region;

Locale: information about linguistic, cultural, and technological conventions for use in formatting data for presentation.

Rule of thumb:

  • All DateFormatters should use the locale and the timezone of the device;
  • All NumberFormatter, in particular those with numberStyle set to .currency (for the sake of this article) should use a specific locale so that prices are not shown in the wrong currency.

General notes on formatters

Let's start by stating the obvious.

Since iOS 10, Foundation (finally) provides ISO8601DateFormatter, which, alongside with DateFormatter and NumberFormatter, inherits from Formatter.

Formatter locale property timeZone property
ISO8601DateFormatter
DateFormatter
NumberFormatter

In an app that only consumes data from an API, the main purpose of ISO8601DateFormatter is to convert strings to dates (String -> Date) more than the inverse. DateFormatter is then used to format dates (Date -> String) to ultimately show the values in the UI. NumberFormatter instead, converts numbers (prices in the vast majority of the cases) to strings (NSNumber/Decimal -> String).

Formatting dates 🕗 🕝 🕟

It seems the following 4 are amongst the most common ISO 8601 formats, including the optional UTC offset.

  • A: 2019-10-02T16:53:42
  • B: 2019-10-02T16:53:42Z
  • C: 2019-10-02T16:53:42-02:00
  • D: 2019-10-02T16:53:42.974Z

In this article I'll stick to these formats.

The 'Z' at the end of an ISO8601 date indicates that it is in UTC, not a local time zone.

Locales

Converting strings to dates (String -> Date) is done using ISO8601DateFormatter objects set up with various formatOptions.

Once we have a Date object, we can deal with the formatting for the presentation. Here, the locale is important and things can get a bit tricky. Locales have nothing to do with timezones, locales are for applying a format using a language/region.

Locale identifiers are in the form of <language_identifier>_<region_identifier> (e.g. en_GB).

We should use the user's locale when formatting dates (Date -> String). Consider a British user moving to Italy, the apps should keep showing a UI localized in English, and the same applies to the dates that should be formatted using the en_GB locale. Using the it_IT locale would show "2 ott 2019, 17:53" instead of the correct "2 Oct 2019 at 17:53".

Locale.current, shows the locale set (overridden) in the iOS simulator and setting the language and regions in the scheme's options comes handy for debugging.

Some might think that it's acceptable to use Locale.preferredLanguages.first and create a Locale from it with let preferredLanguageLocale = Locale(identifier: Locale.preferredLanguages.first!) and set it on the formatters. I think that doing so is not great since we would display dates using the Italian format but we won't necessarily be using the Italian language for the other UI elements as the app might not have the IT localization, causing an inconsistent experience. In short: don't use preferredLanguages, best to use Locale.current.

Apple strongly suggests using en_US_POSIX pretty much everywhere (1, 2). From Apple docs:

[...] if you're working with fixed-format dates, you should first set the locale of the date formatter to something appropriate for your fixed format. In most cases the best locale to choose is "en_US_POSIX", a locale that's specifically designed to yield US English results regardless of both user and system preferences. "en_US_POSIX" is also invariant in time (if the US, at some point in the future, changes the way it formats dates, "en_US" will change to reflect the new behaviour, but "en_US_POSIX" will not), and between machines ("en_US_POSIX" works the same on iOS as it does on OS X, and as it it does on other platforms).

Once you've set "en_US_POSIX" as the locale of the date formatter, you can then set the date format string and the date formatter will behave consistently for all users.

I couldn't find a really valid reason for doing so and quite frankly using the device locale seems more appropriate for converting dates to strings.

Here is the string representation for the same date using different locales:

  • en_US_POSIX: May 2, 2019 at 3:53 PM
  • en_GB: 2 May 2019 at 15:53
  • it_IT: 2 mag 2019, 15:53

The above should be enough to show that en_US_POSIX is not what we want to use in this case, but it has more to do with maintaining a standard for communication across machines. From this article:

"[...] Unless you specifically need month and/or weekday names to appear in the user's language, you should always use the special locale of en_US_POSIX. This will ensure your fixed format is actually fully honored and no user settings override your format. This also ensures month and weekday names appear in English. Without using this special locale, you may get 24-hour format even if you specify 12-hour (or visa-versa). And dates sent to a server almost always need to be in English."

Timezones

Stating the obvious one more time:

Greenwich Mean Time (GMT) is a time zone while Coordinated Universal Time (UTC) is a time standard. There is no time difference between them.

Timezones are fundamental to show the correct date/time in the final text shown to the user. The timezone value is taken from macOS and the iOS simulator inherits it, meaning that printing TimeZone.current, shows the timezone set in the macOS preferences (e.g. Europe/Berlin).

Show me some code

Note that in the following example, we use GMT (Greenwich Mean Time) and CET (Central European Time), which is GMT+1. Mind that it's best to reuse formatters since the creation is expensive.

class CustomDateFormatter {
    
    private let dateFormatter: DateFormatter = {
        let dateFormatter = DateFormatter()
        dateFormatter.dateStyle = .medium
        dateFormatter.timeStyle = .short
        return dateFormatter
    }()
    
    private let locale: Locale
    private let timeZone: TimeZone
    
    init(locale: Locale = .current, timeZone: TimeZone = .current) {
        self.locale = locale
        self.timeZone = timeZone
    }
    
    func string(from date: Date) -> String {
        dateFormatter.locale = locale
        dateFormatter.timeZone = timeZone
        return dateFormatter.string(from: date)
    }
}
let stringA = "2019-11-02T16:53:42"
let stringB = "2019-11-02T16:53:42Z"
let stringC = "2019-11-02T16:53:42-02:00"
let stringD = "2019-11-02T16:53:42.974Z"

// The ISO8601DateFormatter's extension (redacted)
// internally uses multiple formatters, each one set up with different
// options (.withInternetDateTime, .withFractionalSeconds, withFullDate, .withTime, .withColonSeparatorInTime)
// to be able to parse all the formats.
// timeZone property is set to GMT.

let dateA = ISO8601DateFormatter.date(from: stringA)!
let dateB = ISO8601DateFormatter.date(from: stringB)!
let dateC = ISO8601DateFormatter.date(from: stringC)!
let dateD = ISO8601DateFormatter.date(from: stringD)!

var dateFormatter = CustomDateFormatter(locale: Locale(identifier: "en_GB"), timeZone: TimeZone(identifier: "GMT")!)
dateFormatter.string(from: dateA) // 2 Nov 2019 at 16:53
dateFormatter.string(from: dateB) // 2 Nov 2019 at 16:53
dateFormatter.string(from: dateC) // 2 Nov 2019 at 18:53
dateFormatter.string(from: dateD) // 2 Nov 2019 at 16:53

dateFormatter = CustomDateFormatter(locale: Locale(identifier: "it_IT"), timeZone: TimeZone(identifier: "CET")!)
dateFormatter.string(from: dateA) // 2 nov 2019, 17:53
dateFormatter.string(from: dateB) // 2 nov 2019, 17:53
dateFormatter.string(from: dateC) // 2 nov 2019, 19:53
dateFormatter.string(from: dateD) // 2 nov 2019, 17:53

Using the CET timezone also for ISO8601DateFormatter, the final string produced for dateA would respectively be "15:53" when formatted with GMT and "16:53" when formatted with CET. As long as the string passed to ISO8601DateFormatter is in UTC, it's irrelevant to set the timezone on the formatter.

Apple suggests to set the timeZone property to UTC with TimeZone(secondsFromGMT: 0), but this is irrelevant if the string representing the date already includes the timezone. If your server returns a string representing a date that is not in UTC, it's probably because of one of the following 2 reasons:

  1. it's not meant to be in UTC (questionable design decision indeed) and therefore the timezone of the device should be used instead;
  2. the backend developers implemented it wrong and they should add the 'Z 'at the end of the string if what they intended is to have the date in UTC.

In short:

All DateFormatters should have timezone and locale set to .current and avoid handling non-UTC string if possible.

Formatting currencies € $ ¥ £

The currency symbol and the formatting of a number should be defined via a Locale, and they shouldn't be set/changed on the NumberFormatter. Don't use the user's locale (Locale.current) because it could be set to a region not supported by the app.

Let's consider the example of a user's locale to be en_US, and the app to be available only for the Italian market. We must set a locale Locale(identifier: "it_IT") on the formatter, so that:

  • prices will be shown only in Euro (not American Dollar)
  • the format used will be the one of the country language (for Italy, "12,34 €", not any other variation such as "€12.34")
class CurrencyFormatter {
    
    private let locale: Locale
    
    init(locale: Locale = .current) {
        self.locale = locale
    }

    func string(from decimal: Decimal,
                overriddenCurrencySymbol: String? = nil) -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        if let currencySymbol = overriddenCurrencySymbol {
            // no point in doing this on a NumberFormatter ❌
            formatter.currencySymbol = currencySymbol
        }
        formatter.locale = locale
        return formatter.string(from: decimal as NSNumber)!
    }
}
let itCurrencyFormatter = CurrencyFormatter(locale: Locale(identifier: "it_IT"))
let usCurrencyFormatter = CurrencyFormatter(locale: Locale(identifier: "en_US"))
let price1 = itCurrencyFormatter.string(from: 12.34) // "12,34 €" ✅
let price2 = usCurrencyFormatter.string(from: 12.34) // "$12.34" ✅

let price3 = itCurrencyFormatter.string(from: 12.34, overriddenCurrencySymbol: "₿") // "12,34 ₿" ❌
let price4 = usCurrencyFormatter.string(from: 12.34, overriddenCurrencySymbol: "₿") // "₿ 12.34" ❌

In short:

All NumberFormatters should have the locale set to the one of the country targeted and no currencySymbol property overridden (it's inherited from the locale).

Languages 🇬🇧 🇮🇹 🇳🇱

Stating the obvious one more time, but there are very rare occasions that justify forcing the language in the app:

func setLanguage(_ language: String) {
    let userDefaults = UserDefaults.standard
    userDefaults.set([language], forKey: "AppleLanguages")
}

The above circumvents the Apple localization mechanism and needs an app restart, so don't do it and localize the app by the book:

  • add localizations in Project -> Localizations;
  • create a Localizable.strings file and tap the localize button in the inspector;
  • always use NSLocalizedString() in code.

Let's consider this content of Localizable.strings (English):

"kHello" = "Hello";
"kFormatting" = "Some formatting 1. %@ 2. %d.";

and this for another language (e.g. Italian) Localizable.strings (Italian):

"kHello" = "Ciao";
"kFormatting" = "Esempio di formattazione 1) %@ 2) %d.";

Simple localization

Here's the trivial example:

let localizedString = NSLocalizedString("kHello", comment: "")

If Locale.current.languageCode is it, the value would be 'Ciao', and 'Hello' otherwise.

Formatted localization

For formatted strings, use the following:

let stringWithFormats = NSLocalizedString("kFormatting", comment: "")
String.localizedStringWithFormat(stringWithFormats, "some value", 3)

As before, if Locale.current.languageCode is it, value would be 'Esempio di formattazione 1) some value 2) 3.', and 'Some formatting 1) some value 2) 3.' otherwise.

Plurals localization

For plurals, create a Localizable.stringsdict file and tap the localize button in the inspector. Localizable.strings and Localizable.stringsdict are independent, so there are no cross-references (something that often tricked me).

Here is a sample content:

<dict>
    <key>kPlurality</key>
    <dict>
        <key>NSStringLocalizedFormatKey</key>
        <string>Interpolated string: %@, interpolated number: %d, interpolated variable: %#@COUNT@.</string>
        <key>COUNT</key>
        <dict>
            <key>NSStringFormatSpecTypeKey</key>
            <string>NSStringPluralRuleType</string>
            <key>NSStringFormatValueTypeKey</key>
            <string>d</string>
            <key>zero</key>
            <string>nothing</string>
            <key>one</key>
            <string>%d object</string>
            <key>two</key>
            <string></string>
            <key>few</key>
            <string></string>
            <key>many</key>
            <string></string>
            <key>other</key>
            <string>%d objects</string>
        </dict>
    </dict>
</dict>

Localizable.stringsdict undergo the same localization mechanism of its companion Localizable.strings. It's mandatory to only implement 'other', but an honest minimum includes 'zero', 'one', and 'other'. Given the above content, the following code should be self-explanatory:

let localizedHello = NSLocalizedString("kHello", comment: "") // from Localizable.strings
let stringWithPlurals = NSLocalizedString("kPlurality", comment: "") // from Localizable.stringsdict
String.localizedStringWithFormat(stringWithPlurals, localizedHello, 42, 1)

With the en language, the value would be 'Interpolated string: Hello, interpolated number: 42, interpolated variable: 1 object.'.


Use the scheme's option to run with a specific Application Language (it will change the current locale language and therefore also the output of the DateFormatters).

If the language we've set or the device language are not supported by the app, the system falls back to en.

References

So... that's all folks. 🌍