Loading translations with NSLocalizedString dynamically at runtime
One of the best things about developing for iOS is having the power of the amazing frameworks that the Apple platforms have. Foundation is easily one of the best platform frameworks out there and in this post I want to show you how to use a gem that not a lot of people is aware of.
If you care about your users I’m sure you have already used NSLocalizedString
many times to easily support localization in your Apps. Non english developers tend to do it almost by default but if you are one of the ones that still doesn’t care about localizing your apps you should take a look at Apple guides.
In short, NSLocalizedString
works by looking for the value of the given key in a Localizable.strings
file. The file needs to be inside a folder with the locale identifier and the .lproj
suffix (en.lproj
, es_ES.lproj
, etc). With this, the system can look for the correct string in the proper language depending on the locale configuration of the user’s phone.
It may seem a simple thing but the rules to pick the correct language are not straightforward. Languages are not as simple as “English” or “Spanish”. There are many combinations possible, from “U.K. English” versus “U.S. English” to “Spanish” and its many variants between Spain and the different countries in America. And without entering in weird combinations like “en_ES” that don’t really exist in the real world but they can in a user’s device.
This is all good if the languages are bundled with your App, Foundation takes care of it. But what if the text comes from an API? The easies way for you is to use the HTTP Accept-Language
header and let the API deal with the localization. Any good server side application will have a proper localization framework, and if not remember that you can just use Swift on the server and build proper backend software ;)
But there are some cases where the backend can’t take care of it. In a small side project of mine I just have a JSON stored in a server. There is no code running there, just file serving. The issue is that I still want to support different languages so the JSON serves the strings in the different languages that I support in an object structure.
We can replicate that in Swift:
let strings = [
"es": [
"runtime": "es texto"
],
"es_ES": [
"runtime": "es_ES texto"
],
"es_MX": [
"runtime": "es_MX texto"
],
"en": [
"runtime": "en string"
]
]
This is an example of a simple structure that provides the same text in different languages. So now comes the tricky part, how can I select the proper string at runtime? I’ve seen some people do hardcoded if
statements checking for specific language identifiers, without taking into account all the complex logic behind the locale system so we can’t use that. But we have Foundation, so let’s leverage its power!
In all honestly I would have expected this to be simple. I would like that the code that selects the correct language based on the user’s locale would be exposed in a public API directly, but is not. Luckily our friend NSLocalizedString
has a version that accepts many more parameters:
func NSLocalizedString(
_ key: String,
tableName: String? = default,
bundle: Bundle = default,
value: String = default, comment: String
) -> String
The two important new parameters are tableName
and bundle
. By default when using NSLocalizedString the system uses the App main bundle and the Localizable table, table meaning the name of the strings file. So to hook into the localization system we just need to convert the object structure that we have in memory to the proper file hierarchy that is expected on disk.
As an example of how to do it (obviously you should take care of error handling to make this production ready):
First we create the Bundle directory itself, the one that will contain the different languages.
if manager.fileExists(atPath: bundlePath.path) == false {
try! manager.createDirectory(at: bundlePath, withIntermediateDirectories: true, attributes: nil)
}
Then we can iterate over the translations
for language in translations {
and create a directory for each language with the suffix .lproj
let lang = language.key
let langPath = bundlePath.appendingPathComponent("\(lang).lproj", isDirectory: true)
if manager.fileExists(atPath: langPath.path) == false {
try! manager.createDirectory(at: langPath, withIntermediateDirectories: true, attributes: nil)
}
then we need to grab all the key value pairs in the language and generate a proper .strings
file with them.
language.value.reduce("", { $0 + "\"\($1.key)\" = \"\($1.value)\";\n" })
And finally just save it to a file
let filePath = langPath.appendingPathComponent("\(tableName).strings")
let data = res.data(using: .utf32)
manager.createFile(atPath: filePath.path, contents: data, attributes: nil)
Now we have a Bundle
that gives access to the localization files with he proper structure for the system to know where to look for.
So you can just use the NSLocalizedString
passing the bundle and the .strings
file name. As an alternative you can also just call the localizedString(forKey:value:table)
on the Bundle, is what NSLocalizedString
does under the hood.
That’s it! You just loaded the correct translation dynamically at runtime relying on all the power of Foundation.
You can check a complete implementation in RuntimeLocalizable