Safer Localization in SwiftUI
Cihat Gündüz
Posted on July 19, 2020
Now that we've seen many improvements to SwiftUI during WWDC this year, including a possibility to go full-SwiftUI even on App
level (I consider AppDelegate
deprecated now), I just started working on my first serious pure SwiftUI-driven app.
While most of the UI code I wrote was a lot of fun so far and I'm learning some tricks to deal with data management along the way, what I was very curious to see was if and how SwiftUI changes & improves upon the localization APIs as well. The examples I had seen so far from Apple and all the popular posts and guides were not really going into any detail there and it just looked like localization is somehow magically baked into the SwiftUI views.
And that actually turned out to be the case, at least simply specifying a view like Text("E-Mail")
and putting the key "E-Mail"
into the Localizable.strings
file + providing different translations for other languages seems to just work.
Digging a little further into how this works, I quickly came across the type providing the magic here: LocalizedStringKey
. SwiftUI views like Text
accept instances of this type as their first parameter and because LocalizedStringKey
conforms to the ExpressibleByStringLiteral
protocol, one can use String literals like "E-Mail"
in my example above and they are turned into instances of the LocalizedStringKey
struct automatically.
Having a look into what other protocols the LocalizedStringKey
conforms to, I found ExpressibleByStringInterpolation
which is a protocol that was introduced in its new form with Swift 5 via SE-228. Until I saw how Apple used this protocol for localization, I did not know about its existence at all, but of course there was already an NSHipster article about it, which only undermines it's usefulness. Here's an example what it looks like on the usage side:
Text("Hello, \(name)") // => "Hello, %@"
Text("Last updated \(date, formatter: dateFormatter)") // => "Last updated %@"
While all these things make the new APIs much more readable and easier to use than the old NSLocalizedString
APIs, they do share some of their biggest flaws:
- There are no compiler checks to ensure a key is (still) available in localization files
- There is no support whatsoever to keep keys consistent across languages
- There is no autocompletion, leading to many misspellings & exact name lookups
I had already written about this in detail in my blog post Localization in Swift like a Pro last year, where I also presented a solution for these problems using my tool BartyCrouch which I had started back in 2016 and the amazing SwiftGen, which I can only recommend for any serious app project.
But the workflow was designed for Interface Builder and NSLocalizedString
and while it does continue to work because you could simply pass NSLocalizedString
objects into SwiftUI views (they also accept plain String
s), it didn't feel natural to the new way of things in SwiftUI, so I've started exploring new approaches here.
First, I checked if there's already a new approach by the SwiftGen contributors and found that there was already some discussion going on regarding the new LocalizedStringKey
struct. But things have not settled yet and it will probably take a lot more consideration and experience to get the localization part right. I decided to skip the "no autocompletion" issue for now as that can only be provided by generated code.
But I tried to find a new way to improve on the other two issues and could quickly find a fix for the "consistency across languages" one as for that I could simply use some lesser known features of BartyCrouch:
I use this .bartycrouch.toml
configuration file:
[update]
tasks = ["normalize"]
[update.normalize]
paths = ["Shared/App/Resources"]
sourceLocale = "en"
harmonizeWithSource = true
sortByKeys = true
[lint]
paths = ["Shared/App/Resources"]
duplicateKeys = true
emptyValues = true
Note that Shared/App/Resources
is the path my .lproj
folders are within, including the Localizable.strings
files.
Then I configured this build script above the "Compile Sources" step:
if which bartycrouch > /dev/null; then
bartycrouch update -x
bartycrouch lint -x
else
echo "warning: BartyCrouch not installed, download it from https://github.com/Flinesoft/BartyCrouch"
fi
The update
subcommand with the normalize
option harmonizeWithSource
set to true
ensures that all keys I add to the sourceLocale
"en" will automatically be added to any other languages my app supports. Additionally, I prefer to sort all keys alphabetically (sortByKeys
) to keep any keys with the same prefix like Onboarding.LoginScreen.
grouped together.
The lint
subcommand with emptyValues
set to true
ensures that I get warnings within Xcode for any empty localization values. The duplicateKeys
option ensures that I also get warned if I accidentally would add a key to the "en" localization manually to prevent ambiguity.
So BartyCrouch seems to continue helping with localization, even with SwiftUI. But it requires a manual step to add a key to the source language at the moment and it can't check for the existence of the key in the localization files.
To explore how I can fix this, I wrote a shadow type named SafeLocalizedStringKey
that conforms to the same protocols and passes the localized Strings along to LocalizedStringKey
. Additionally, I overloaded all SwiftUI view initializers that were using LocalizedStringKey
with a safe:
variant that has a validation for the existence of keys that would only run during development, but would safely fallback to the key in production. I achieved that by using this validation method:
func validateAvailabilityInSupportedLocales(stringsKey: String) {
#if DEBUG
let missingLocales: [String] = Bundle.main.localizations.filter { locale in
let localeBundle = Bundle(path: Bundle.main.path(forResource: locale, ofType: "lproj")!)!
let localizedValue = NSLocalizedString(stringsKey, bundle: localeBundle, comment: "")
return localizedValue == stringsKey || localizedValue.isEmpty
}
guard missingLocales.isEmpty else {
assertionFailure("Missing locales \(missingLocales) for localized string key '\(stringsKey)'.")
return
}
#endif
}
This code first filters the locales that don't have an entry for the key (in which case the value is equal to the key) and reports them as an assertionFailure
, which ensures that this code doesn't crash the app in production even if the DEBUG
was set for production builds accidentally.
You can find the full implementation of the SafeLocalizedStringKey
type in this GitHub gist, including the safe:
overload extensions for 17 different SwiftUI views. If you copy that file into your SwiftUI project, you should be able to replace any calls like Text("E-Mail")
with the safe alternative Text(safe: "E-Mail")
. This will run the additional validation and prevent you from forgetting to add the key to your localization files as it will crash the app during development with a clear warning.
So far so good, I'm happy enough with this solution for now. Improvements were made on all of the localization flaws.
Of course it would be even better to have a solution that automatically takes care of adding the keys to the localization files like the "Pro localization workflow" in my previous article, but that's out of scope of this exploration as I want to focus on getting my app closer to release first.
The only other improvement I actually did was to write a custom lint check using AnyLint to make sure I never forget to use the safe:
APIs over the default ones. The check even autocorrects the non-safe API usage to safe APIs automatically and could be run as a pre-commit hook. I've uploaded it as a GitHub gist in case you want to use it.
That's it from me today, I hope you found something useful in this article. What do you think of my approach taken here? Do you have other ideas that can improve localization with SwiftUI? Let me know by leaving a comment below.
Thank you for your time!
Posted on July 19, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.