Safer Localization in SwiftUI
Tags: ios, swift, localization, swiftui
Published: 2020-07-19 17:25:41

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](https://github.com/apple/swift-evolution/blob/master/proposals/0228-fix-expressiblebystringinterpolation.md). 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](https://nshipster.com/expressiblebystringinterpolation/) about it, which only undermines it's usefulness. Here's an example what it looks like on the usage side:

```swift
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:

1. There are **no compiler checks** to ensure a key is (still) available in localization files
2. There is no support whatsoever to keep keys **consistent across languages**
3. 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](https://medium.com/@Jeehut/localization-in-swift-like-a-pro-48164203afe2) last year, where I also presented a solution for these problems using my tool [BartyCrouch](https://github.com/Flinesoft/BartyCrouch) which I had started back in 2016 and the amazing [SwiftGen](https://github.com/SwiftGen/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](https://github.com/SwiftGen/SwiftGen/issues/685) 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:

```toml
[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:

```shell
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:

```swift
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](https://gist.github.com/Jeehut/c8c9a8caf8dc7c02583a4a07dfbb37aa), 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](https://github.com/Flinesoft/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](https://gist.github.com/Jeehut/c3721a53aca3b7fb8c2e14f4bda32935) 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!