Localization can be as much fun as unit testing. And like unit testing, it can be fun if it makes your code more robust and flexible (dependency injection and encapsulation anyone?). For example, once you internationalize your app and start viewing it in right-to-left languages, you’ll notice areas you’ve been using auto-layout wrong and start better habits such as using UIStackViews over UILabel text-alignments or tweaking hugging and resistance priorities. There’s one nagging thing that keeps coming up with localization though…
NSLocalizedString
Our old friend, `NSLocalizedString(“some.dialog.message”, comment: “My main dialog message for tutorial”)`. These localization API’s can be really bloated and ugly.. it can get out of control. Take this for example:
let title = NSLocalizedString("tutorial.alert.title", comment: "Title of main tutorial dialog") let message: String = .localizedStringWithFormat(NSLocalizedString("one.corner.label.1", comment: "First corner of main screen inserting number"), 9876)
There’s so many un-Swifty things about this; there’s the “NS-” prefix, some irrelevant comments in the code, `localizedStringWithFormat` hasn’t been split up to function parameters, formatting with locale is some nested call.. I don’t think it can get any worse.
Getting Swifty With It
One common technique in making `NSLocalizedString` more Swifty is wrapping it up in a String
extension:
extension String { var localized: String { return NSLocalizedString(self, comment: "") } func localized(comment: String) -> String { return NSLocalizedString(self, comment: comment) } } print("some.dialog.message".localized)
Looks good, although it still doesn’t solve comments from being infused in your code. Furthermore, there’s one big deal-breaker with this: Xcode will not export the localization to a .xliff file since NSLocalizedString is being used dynamically at runtime here.
Xcode’s localization export depends on the hardcoded macro `NSLocalizedString` to do the search for export, so we still have to use them. Yes, NSLocalizedString is a macro! Hopefully the localization export feature can be rebuilt on top of Xcode 9+’s refactoring engine 😉
Now with Xcode Support™
Let’s take our last attempt further to get the right API while respecting Xcode support. Using Enums
to handle this would be sweet, but you have to use literals so this won’t compile:
enum Localizable: String { //Compile error: Raw value for enum case must be a literal case tutorialAlert = NSLocalizedString("tutorial.alert.title", comment: "Some comment") }
That’s fine; actually extending a struct
would work out better because then I can use extensions to add more localized strings instead of using cases. Let’s adjust:
struct Localizable { private init() { } } extension Localizable { static let title = NSLocalizedString("tutorial.alert.title", comment: "Title of main tutorial dialog") static let message = NSLocalizedString("one.corner.label.1", comment: "First corner of main screen inserting number") } // ViewController.swift let title = Localizable.title let message: String = .localizedStringWithFormat(Localizable.message, 9876)
Still needs a bit more work.
Localization Micro-Library
When refactoring Swift code, one question I ask is how I can leverage inference. I’d like to end up with something like this:
let title: String = .title let message: String = .localizedStringWithFormat(.message, 9876)
To achieve this, we’d have to move all the extensions to String
, which would be obnoxious and pollute our String
API. Instead, we’ll have to extend the `Localizable` struct along with String
:
extension String { static func localized(_ key: Localizable) -> String { return key.contents } } struct Localizable { fileprivate let contents: String init(_ contents: String) { self.contents = contents } } extension Localizable { static let title = Localizable(NSLocalizedString("tutorial.dialog.title", comment: "Title of main tutorial dialog")) static let message = Localizable(NSLocalizedString("tutorial.dialog.message", comment: "First corner of main screen inserting number")) }
Notice how we adjusted the Localizable
static properties to return itself for later inference by storing the localized string within its initializer. Now we can do this:
let title: String = .localized(.title)
It’s not as slim as I aimed (which was too terse?), but it’s reasonable. And still reasonable even though you have to manage the NSLocalizedString
list through Localizable
static properties, but there’s no way around that – at least now they’re consolidated and reusable from one place, also making comment management easier.
Now with Locale Support™
For extending .localizedStringWithFormat
, there’s still some work to do. Below will give *empty memory values* at runtime, even though it *dangerously compiles*:
extension String { // WRONG: Don't do this! static func localizedFormat(_ key: Localizable, _ arguments: CVarArg...) -> String { return String(format: key.contents, arguments) } static func localizedLocale(_ key: Localizable, _ arguments: CVarArg...) -> String { return .localizedStringWithFormat(key.contents, arguments) } }
There are nuances we have to deal with when passing variable arguments to other functions since `CVarArg` is a bridge to low-level C-language API’s. We have to facilitate passing the `CVarArg` arguments to other functions via pointers using Swift’s “withVaList“:
extension String { /// A string initialized by using format as a template into which values in argList are substituted according the current locale information. private static var vaListHandler: (_ key: String, _ arguments: CVaListPointer, _ locale: Locale?) -> String { // https://stackoverflow.com/questions/42428504/swift-3-issue-with-cvararg-being-passed-multiple-times return { return NSString(format: $0, locale: $2, arguments: $1) as String } } /// Returns a string created by using a given format string as a template into which the remaining argument values are substituted. /// Equivalent to `String(format: value)`. static func localizedFormat(_ key: Localizable, _ arguments: CVarArg...) -> String { return withVaList(arguments) { vaListHandler(key.contents, $0, nil) } as String } /// Returns a string created by using a given format string as a template into which the /// remaining argument values are substituted according to the user’s default locale. /// Equivalent to `String.localizedStringWithFormat(value, arguments)`. static func localizedLocale(_ key: Localizable, _ arguments: CVarArg...) -> String { return withVaList(arguments) { vaListHandler(key.contents, $0, .current) } as String } }
Now it works and we end up with this:
// Before let title = NSLocalizedString("tutorial.alert.title", comment: "Title of main tutorial dialog") let message: String = .localizedStringWithFormat(NSLocalizedString("one.corner.label.1", comment: "First corner of main screen inserting number"), 9876) let caption = String(format: NSLocalizedString("current.app.name.version", comment: "Center display of app name and current version"), "v1.1") // After let title: String = .localized(.tutorialAlertTitle) let message: String = .localizedLocale(.oneCornerLabel1, 9876) let caption: String = .localizedFormat(.currentAppNameVersion, "v1.1")
And Xcode’s localization export to .xliff works perfectly and with comments since NSLocalizedString
is spelled out in the Localizable
static extensions 👍
The Full Example
Below is how the result would look like in an app..
Before:
class ViewController1: UIViewController { ... override func viewDidLoad() { super.viewDidLoad() centerLabel.text = String(format: NSLocalizedString("current.app.name.version", comment: "Center display of app name and current version"), Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String, Bundle.main.infoDictionary?[kCFBundleVersionKey as String] as! String) startTutorial() } /// Launched to start tutorial func startTutorial() { let alertController = UIAlertController( title: NSLocalizedString("tutorial.alert.title", comment: "Title of main tutorial dialog"), message: NSLocalizedString("tutorial.alert.message", comment: "Message of main tutorial dialog"), preferredStyle: .alert ) alertController.addAction(UIAlertAction(title: NSLocalizedString("tutorial.alert.accept", comment: "OK button of main tutorial dialog"), style: .default) { _ in self.firstLabel.text = .localizedStringWithFormat(NSLocalizedString("one.corner.label.1", comment: "First corner of main screen translating number 1"), 1) self.secondLabel.text = .localizedStringWithFormat(NSLocalizedString("two.corner.label.2", comment: "Second corner of main screen translating number 2"), 2) self.thirdLabel.text = .localizedStringWithFormat(NSLocalizedString("three.corner.label.3", comment: "Third corner of main screen translating number 3"), 3) self.fourthLabel.text = .localizedStringWithFormat(NSLocalizedString("four.corner.label.4", comment: "Fourth corner of main screen translating number 4"), 4) }) present(alertController, animated: true) } }
After:
class ViewController2: UIViewController { ... override func viewDidLoad() { super.viewDidLoad() centerLabel.text = .localizedFormat(.currentAppNameVersion, Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String, Bundle.main.infoDictionary?[kCFBundleVersionKey as String] as! String ) startTutorial() } /// Launched to start tutorial func startTutorial() { let alertController = UIAlertController( title: .localized(.tutorialAlertTitle), message: .localized(.tutorialAlertMessage), preferredStyle: .alert ) alertController.addAction(UIAlertAction(title: .localized(.tutorialAlertAccept), style: .default) { _ in self.firstLabel.text = .localizedLocale(.oneCornerLabel1, 1) self.secondLabel.text = .localizedLocale(.twoCornerLabel2, 2) self.thirdLabel.text = .localizedLocale(.threeCornerLabel3, 3) self.fourthLabel.text = .localizedLocale(.fourCornerLabel4, 4) }) present(alertController, animated: true) } }
Localizable.swift (micro-library):
struct Localizable { fileprivate let contents: String init(_ contents: String) { self.contents = contents } } extension String { /// A string initialized by using format as a template into which values in argList are substituted according the current locale information. private static var vaListHandler: (_ key: String, _ arguments: CVaListPointer, _ locale: Locale?) -> String { // https://stackoverflow.com/questions/42428504/swift-3-issue-with-cvararg-being-passed-multiple-times return { return NSString(format: $0, locale: $2, arguments: $1) as String } } /// Returns a localized string. static func localized(_ key: Localizable) -> String { return key.contents } /// Returns a string created by using a given format string as a template into which the remaining argument values are substituted. /// Equivalent to `String(format: value)`. static func localizedFormat(_ key: Localizable, _ arguments: CVarArg...) -> String { return withVaList(arguments) { vaListHandler(key.contents, $0, nil) } as String } /// Returns a string created by using a given format string as a template into which the /// remaining argument values are substituted according to the user’s default locale. /// Equivalent to `String.localizedStringWithFormat(value, arguments)`. static func localizedLocale(_ key: Localizable, _ arguments: CVarArg...) -> String { return withVaList(arguments) { vaListHandler(key.contents, $0, .current) } as String } }
AppLocalizable.swift:
// MARK: - Main Scene extension Localizable { static let currentAppNameVersion = Localizable(NSLocalizedString("current.app.name.version", comment: "Center display of app name and current version")) static let oneCornerLabel1 = Localizable(NSLocalizedString("one.corner.label.1", comment: "First corner of main screen translating number 1")) static let twoCornerLabel2 = Localizable(NSLocalizedString("two.corner.label.2", comment: "Second corner of main screen translating number 2")) static let threeCornerLabel3 = Localizable(NSLocalizedString("three.corner.label.3", comment: "Third corner of main screen translating number 3")) static let fourCornerLabel4 = Localizable(NSLocalizedString("four.corner.label.4", comment: "Fourth corner of main screen translating number 4")) } // MARK: - Tutorial extension Localizable { static let tutorialAlertTitle = Localizable(NSLocalizedString("tutorial.alert.title", comment: "Tutorial intro and getting started")) static let tutorialAlertMessage = Localizable(NSLocalizedString("tutorial.alert.message", comment: "Tutorial submitting feedback and contact")) static let tutorialAlertAccept = Localizable(NSLocalizedString("tutorial.alert.accept", comment: "Tutorial done and dismiss")) }
You can download the working sample and give it a try.
Happy Coding!!
Great article. Could you take a look at a library i wrote to help with localizations and if you have any suggestions it would be appreciated. https://github.com/willpowell8/LocalizationKit_iOS
How can I return a static default value when the key for the translatable string does not exist in the localization file?
There’s really no good way to figure out if a key exists or not, so you can make a best guess that if the localization key equals the localization value, it does not exist: `Bundle.main.localizedString(forKey: “my.key.1”, value: nil, table: nil) != “my.key.1” ? NSLocalizedString(“my.key.1”) : NSLocalizedString(“my.default”)`
Great article. Could you take a look at a library i wrote to help with localizations and if you have any suggestions it would be appreciated. https://github.com/willpowell8/LocalizationKit_iOS