Localising Externally-derived User Facing Strings

5 November 2024

Most of the text in our apps is baked in at build time, whether using strings and stringsdict files, or ideally the String Catalogs that were introduced with Xcode 15. However, on some occasions we may need to derive our text from a backend service, like if our app's content needs to be updated dynamically outside of our usual release cycle, or if we have user journeys based on external content hosted in a CMS. In these scenarios it is easy to lose the benefits of the platform provided localisation tooling. But with a little bit of work, we may continue to utilise NSLocalizedString and maintain all of its benefits, even when deriving our strings from external sources. Today we will explore how to do this.

Language Selection

One under-appreciated feature of NSLocalizedString is its ability to select the correct language to show to the user based on the user's preferences. This sounds simple enough, and for the most part it is. Many users only have one language selected on their device, and apps will be displayed using this language if it is available, defaulting to the application's development language if it is not.

The iOS Settings app, showing a user whose only preferred language is English.

Note that this list is the user's preferred languages. Some users may be fluent in multiple languages, and have a preferred language list that reflects this:

The preferred language list of a user fluent in French, German, and English.

If this user downloads an app whose development language is German that has been localised to French and English, then the app's content will be shown to the user in French. If however the app was only localised to German and English, then German content would be shown to the user. This is because German is listed further up on the preferred languages list to English. Without any work on the part of the developer beyond localising the app, the user is shown the app's content in the most appropriate way available.

Requesting Localised Strings from Backend Services

If we're lucky, our app may integrate with a remote server that implements the Accept-Language header in order to return information using a language that is acceptable to the user. Obviously the server cannot know the user's preference, so the user's preferred languages have to be sent using the Accept-Language header. We can do this by specifying the language codes the user prefers with weighting applied to tell the server how we would like the content to be served. For our user above, this header could look like this:

Accept-Language: fr, de;q=0.9, en;q=0.8

This specifies French, followed by German, then English. We access this list of language identifiers using +[Locale preferredLanguages], which we can then transform in to the value for this header.

However, we might not be so lucky. Perhaps the backend we integrate with doesn't support the Accept-Language header, or maybe it serves data from static files without any code running. In this case, we're going to have to handle language selection ourselves. Fortunately, we can have NSLocalizedString do the heavy lifting for us, just like it does with the strings we supply at build time.

Localised String Recap - Language Directories

When Xcode builds our app, it includes language directories inside the main application bundle whose names are made up of the identifier for the language of the contained strings, suffixed with .lproj:

The language directories (with a Blue tag added for identification) in the app bundle for an app with English, French, German, Spanish, and Japanese translations.

These language directories contain the .strings and .stringsdict files used by the foundation framework to lookup localised string values. Internally, NSLocalizedString is able to use the preferred language identifiers discussed earlier to determine which of these directories to use to lookup the localised strings most appropriate for the user.

If NSLocalizedString can use these directories to lookup localisations, then could it do the same for language directories located elsewhere? An interesting thought…

Using NSLocalizedString with Externally-derived Strings - Building a Languages Bundle

Let's say we have an endpoint that returns us localised strings for a screen in our app, and the file looks like this:

{
  "en": {
    "error_screen.title": "Error",
    "error_screen.body": "Your account has been locked. Please contact customer services.",
    "error_screen.ok": "OK"
  },
  "es": {
    "error_screen.title": "Error",
    "error_screen.body": "Su cuenta ha sido bloqueada. Póngase en contacto con el servicio de atención al cliente.",
    "error_screen.ok": "De acuerdo"
  },
  "fr": {
    "error_screen.title": "Erreur",
    "error_screen.body": "Votre compte a été bloqué. Veuillez contacter le service client.",
    "error_screen.ok": "D'accord"
  }
}

In order to be able to use these strings as localisable strings, we will need to create a set of language directories like the one we saw earlier in our application bundle. To do this, we will create a bundle containing a language directory for each of the language identifiers specified in the JSON that we get from our backend endpoint. First, we have to model the JSON data so that we can decode it:

/// Represents localised strings fetched from a remote endpoint.
struct RemoteStrings: Decodable {
    
    /// The string keys and values, keyed by the language code that they belong
    /// to.
    ///
    /// Decoded from data in the following form:
    /// {
    ///     "en": {
    ///         "string_key": "String Value"
    ///     },
    ///     "fr": {
    ///         "string_key": "Auchean String"
    ///     }
    /// }
    let languageCodeKeyedStrings: [String: [String: String]]
    
    init(from decoder: any Decoder) throws {
        languageCodeKeyedStrings = try decoder.singleValueContainer().decode(Dictionary<String, Dictionary<String, String>>.self)
    }
}

Now that we can decode the remote strings, we need an object to allow us to write out the bundle containing the language directories:

/// Creates the content for a language directories bundle from a
/// ``RemoteStrings`` instance.
final class RemoteStringsLocalisationBundleContentCreator {
    
    /// Writes remote strings in to bundle that can be used by
    /// `NSLocalizedString`.
    /// - Parameters:
    ///   - remoteStrings: The remote strings to be written in to the bundle.
    ///   - directoryURL: The location at which to write the bundle's contents.
    func writeLocalisableFileData(forRemoteStrings remoteStrings: RemoteStrings, inDirectoryAt directoryURL: URL) {
        try? FileManager.default.contentsOfDirectory(atPath: directoryURL.path()).forEach { path in
            try? FileManager.default.removeItem(at: directoryURL.appending(component: path))
        }
        remoteStrings.languageCodeKeyedStrings.forEach { (languageIdentifier, keyedStringValues) in
            writeLocalisableFileData(forKeyedStringValues: keyedStringValues,
                                     languageIdentifier: languageIdentifier,
                                     inDirectoryAt: directoryURL)
        }
    }
    
    private func writeLocalisableFileData(forKeyedStringValues keyedStringValues: [String: String], languageIdentifier: String, inDirectoryAt directoryURL: URL) {
        let lProjDirectoryURL = directoryURL.appendingLProjDirectory(forLanguageIdentifier: languageIdentifier)
        try? FileManager.default.createDirectory(at: lProjDirectoryURL, withIntermediateDirectories: true)
        let propertyListEncoder = PropertyListEncoder()
        propertyListEncoder.outputFormat = .xml
        guard let data = try? propertyListEncoder.encode(keyedStringValues) else { return }
        try? data.write(to: lProjDirectoryURL.appendingStringsFilePathComponents(forTableName: "Localizable"))
    }
}

private extension URL {
    func appendingLProjDirectory(forLanguageIdentifier languageIdentifier: String) -> URL {
        appending(component: languageIdentifier).appendingPathExtension("lproj")
    }
    
    func appendingStringsFilePathComponents(forTableName tableName: String) -> URL {
        appending(component: tableName).appendingPathExtension("strings")
    }
}

This might look a bit complicated at first glance, but it is actually relatively straightforward. In -writeLocalisable​FileDataFor​RemoteStrings:in​DirectoryAtURL: we start by removing any existing language directories from the bundle location to make sure that we start in a clean state. Following this, we loop over each language code from our RemoteStrings instance and pass its localised string key-value pairs to -writeLocalisable​FileDataFor​KeyedStringValues:language​Identifier:in​DirectoryAtURL:. This function creates the language directory inside the bundle, creates an XML property list from the localised string key-value pairs, and writes it out to a file named Localizable.strings, which matches the default table name used by NSLocalizedString.

Once this code is run using the backend data shown above, we get a bundle like this:

The contents of the languages bundle created from the backend data.

Now that we have to ability to write out the contents of our strings bundle, let's wrap it up in an extension on Bundle to make it easy to use:

extension Bundle {
    
    /// The bundle containing the application's remote strings.
    static let remoteStrings: Bundle = {
        if !FileManager.default.fileExists(atPath: Bundle.remoteStringsBundleURL.path) {
            try! FileManager.default.createDirectory(at: Bundle.remoteStringsBundleURL, withIntermediateDirectories: true)
        }
        return Bundle(url: Bundle.remoteStringsBundleURL)!
    }()
    
    private static let remoteStringsBundleURL = URL.temporaryDirectory.appendingPathComponent("RemoteStrings").appendingPathExtension("bundle")
    
    /// Requests that the receiver writes the appropriate localisable file data
    /// in to itself for the given ``RemoteStrings``.
    /// - Parameter remoteStrings: The remote strings to be written in the
    /// receiver.
    func writeLocalisableFileData(forRemoteStrings remoteStrings: RemoteStrings) {
        let remoteStringsLocalisationBundleContentCreator = RemoteStringsLocalisationBundleContentCreator()
        remoteStringsLocalisationBundleContentCreator.writeLocalisableFileData(forRemoteStrings: remoteStrings,
                                                                        inDirectoryAt: bundleURL)
    }
}

The +[Bundle remoteStrings] property creates the remote strings bundle on demand. -writeLocalisable​FileDataForRemoteStrings: allows us to pass an instance of RemoteStrings to the bundle to have the language-specific strings it contains written as language directories in to the bundle using the RemoteStringsLocalisation​BundleContentCreator we created earlier. All that is left to do is access the localisations from our newly created bundle.

Using Remote Localisations in Code

In order to access our externally derived localisations, we can add a simple loose function to our project:

/// Returns a localised string from the remote strings localisation bundle.
/// - Parameter remoteStringKey: The key of the string to be returned.
/// - Returns: The result of calling `NSLocalizedString` with the given key in
/// the remote strings bundle.
func NSLocalizedString(remoteStringKey: String) -> String {
    NSLocalizedString(remoteStringKey, bundle: Bundle.remoteStrings, comment: "")
}

Note that this is passing our "remote strings" bundle to the call to NSLocalizedString, which is how the system knows where to look for the localisation that we are specifying with the given key. We can use this as follows:

titleLabel.text = NSLocalizedString(remoteStringKey: "error_screen.title")

Switching languages will yield the correctly localised string values:

Our app showing the localised string values returned to us by the external endpoint.

We now have localised strings vended externally from the app being displayed correctly based on the user's language settings using a simple call to NSLocalizedString. Our users benefit from seeing content in the language that they expect without any heavy lifting at the call sites where we configure our UI.

Addendum - Live Reload

If the endpoint from which you derive your external strings vends updated localisation values, you will notice that if you fetch them and update the remote strings bundle, the new values will not be shown in your UI. Why is this? Because the contents of bundles are cached when the bundle is loaded, so any localised string values loaded from a bundle will remain the same until the app is terminated and relaunched. In order to avoid this and allow updated strings to be presented to the user, we need to disable this caching. Fortunately the string localisation and bundle mechanisms were created so long ago that it was a time when Apple still provided comprehensive documentation, which we can find here. The "Loading String Resources Into Your Code" section explains the following:

A .strings file whose table name ends with .nocache—for example ErrorNames.nocache​.strings—will not have its contents cached by NSBundle.

All we need to do is amend our RemoteStringsLocalisation​BundleContentCreator so instead of calling appendingStringsFilePath​Components(forTableName: ​"Localizable") it calls appendingStringsFilePath​Components(forTableName: ​"Localizable.nocache"). Our remote strings will no longer be cached, and any updates pulled from the server will be reflected in the app once the remote string bundle's contents is rewritten.