iosdevswift

In-app language change in iOS app

Long live the swizzle!

iOS – and Apple OSs in general – have excellent I18N support. You more or less don’t have to worry about date and number formatting as long as you are following the rules. The essential rules are:

Fairly often, I have requests from clients to implement in-app language change, which should (at least) instantly translate the app. There is no API support in iOS frameworks for this.

It’s not impossible though.

Force-load correct set of translations

First issue is that all the translations are kept inside _LANGUAGECODE.lproj folders, like en.lproj, fr.lproj etc.

When your app starts, iOS looks into UserDefaults.standard.value(forKey: "AppleLanguages") value and loads the corresponding translations from the .lproj folder. It processes all the .xib and .storyboard files plus makes an internal dictionary (or something similar) that NSLocalizedString() is using to load appropriate string.

This is done at the app start and there is no way I know of to force-change the language without restarting the app itself.

So we need to cheat and since iOS runtime is dynamism heaven enabled by Objective-C, we can actually do that.

Maxim Bilan found neat solution which I have converted into Swift 3:

Things now work on their own. This will be enough to instantly translate the app if your app is using LocalizedString() only. If your strings are in IB files though, then you need to force-reload those files.

In the demo app, I am pushing the notification that informs everyone about Locale change and AppDelegate is responding to that by reloading the window.rootController:

let nc = NotificationCenter.default
nc.addObserver(forName: NSLocale.currentLocaleDidChangeNotification, 
               object: nil, 
               queue: OperationQueue.main) {
	[weak self] notification in
	guard let `self` = self else { return }

	let sb = UIStoryboard(name: "Main", bundle: nil)
	let vc = sb.instantiateInitialViewController()
	self.window?.rootViewController = vc
}

Overriding Locale.current

Reloading string translations is not enough, though. Large number of system-wide methods on various types is consulting Locale.current (or rather Locale.autoupdatingCurrent..?) to perform its function.

Example: say you have a UITextField for number input. You set its keyboard to DecimalPad which display decimal separator as lower left button. The caption and the actual character that’s a result of the tap is the value of decimalSeparator property on the currently active Locale.

Further, if you try to convert textField.text to a Double or Decimal, conversion will accept only that one value as valid decimal separator. So if you’ve entered “5,15” and your localeIdentifier is “en_US”, that will be converted to 0, not 5.15. Bummer. While this is solvable issue by re-setting cached Formatters, the keyboard issue mentioned above is not.

Thus you need to create your own custom keyboard (yuck!) or…you can swizzle NSLocale.current property. (Thanks again, Objective-C!)

extension NSLocale {
	fileprivate static func swizzle(selector: Selector) {
		let originalSelector = selector
		let swizzledSelector = #selector(getter: NSLocale.app)
		let originalMethod = class_getClassMethod(self, originalSelector)
		let swizzledMethod = class_getClassMethod(self, swizzledSelector)
		method_exchangeImplementations(originalMethod, swizzledMethod)
	}
}
…

NSLocale.swizzle(selector: #selector(getter: NSLocale.current))

NSLocale.app is my own custom Locale I build in any way I need. I start with system’s original value for autoupdatingCurrent and then add and/or replace components using whatever app-level or user-level settings I have.

Swizzling needs to be done only once, as early as possible in app’s lifecycle.

Demo

I wrote demo app which shows all of this in action. It’s available on GitHub, called LanguageSwitcher. Code is over-commented as guidance what you can do and where to go from there.

Look into the localize() method in demo’s ViewController. This is actually my preferred method to handle instant translations as there’s no loss of user context. It’s more work but it leads to better customer experience.

If you cache your DateFormatter and NumberFormatter instances - as you certainly should - then on language change you need to make sure to re-set up Locale and dateFormat values.

Issues

There’s a non-critical issue with the keyboard: when the keyboard is shown, iOS will cache that generated view. Thus if you do this sequence:

You can see that keyboard view still shows the . but if you tap it, it will correctly enter ,. Hence it’s annoyance at best.

I’m not aware of any way to force-clear the keyboard cache in iOS. If you do, please let me know.