String Localisation Advancements in Xcode 15

27 June 2023

String localisation is the most important step to take for making your app accessible to users across the world. By translating your app's copy to multiple languages, you are able to provide the user experience that your customers expect in locations around the globe. Today we will take a look at how the process of string localisation has been managed on Apple platforms up until now, and then take a dive in to the advancements that have been made in Xcode 15.

A Localisation Recap

App localisations broadly come in two flavours - simple messages that may optionally contain placeholders for content provided at runtime, as well as more advanced examples that vary their message based on pluralisation rules or by the type of device that the app is running on. Let's start with the simple ones:

"welcome_message" = "Welcome!";
"hello_format_string" = "Hello, %@";
"review_rating" = "%1$d out of %2$d people found this helpful";

These simple messages live in a .strings file, which maps a localisation "key" to a value. In the example above, the "welcome_message" key maps to a welcome message ("Welcome!"). We access the value of this localisation in code using the NSLocalizedString function:

/// Produces "Welcome!"
NSLocalizedString("welcome_message", comment: "")

The second of these messages, the one keyed by "hello_format_string" contains a placeholder (the %@ portion of the text). This allows a value to be inserted in to the string at runtime, like the user's name:

let name = "Emily"
/// Produces "Hello, Emily"
String.localizedStringWithFormat(NSLocalizedString("hello_format_string", comment: ""), name)

In the final example we have a localisation that contains two placeholders that have positional specifiers, %1$d referring to the first placeholder, and %2$d referring to the second:

/// Produces "3 out of 4 people found this helpful"
String.localizedStringWithFormat(NSLocalizedString("review_rating", comment: ""), 3, 4)

Why would we want to specify positions like this? Because in some languages it may be preferable to have the order of the arguments reversed i.e the total number of people before the number for people who found the review useful. Code to check the language and swap the parameters passed to localizedStringWithFormat would be error prone, and would need to be revisited whenever new localisations or languages are added to an app. Instead, we have positional specifiers to do the job. When a translation is provided for a language that would prefer the order of the arguments to be swapped, then its value simply has the %2$d placeholder positioned before the %1$d placeholder.

Speaking of other languages, how do we provide translations for them? By duplicating our .strings file and replacing the values with the correctly translated copy. Here's how our messages would look if translated to French:

"welcome_message" = "Bienvenue!";
"hello_format_string" = "Salut, %@";
"review_rating" = "%1$d personnes sur %2$d ont trouvé cela utile";

Each of these copies of the .strings file must be kept in a separate directory, keyed by the locale specifier for the language:

The directory structure for an app localised for English, French, German, Japanese, and Spanish.
The directory structure for an app localised for English, French, German, Japanese, and Spanish.

This produces the following in our Xcode project:

The localised strings files in the Xcode project navigator.
The localised strings files in the Xcode project navigator.

That's how we handle simple localisations, but what about the more complicated ones that vary by plurals and device types? These are handled by .stringsdict files. Let's take a look at an example where we would want to vary a piece of text based on a pluralised item it contains. We may wish to have a string that says something along the lines of "You have 5 new messages". We could use a simple localisation like we saw earlier, something along the lines of "new_messages" = "You have %d new messages". However, if the user only has one new message, this would produce "You have 1 new messages", whereas in this scenario we would want it to say "You have 1 new message". A .stringsdict file allows us to cater for both of these scenarios:

A localised string that varies based on its treatment of a plural value.
A localised string that varies based on its treatment of a plural value.

Our string "key" here is "new_messages", which we use in the same way as we did with the simple localisation string keys:

/// Produces "You have 1 new message"
String.localizedStringWithFormat(NSLocalizedString("new_messages", comment: ""), 1)

/// Produces You have 2 new messages
String.localizedStringWithFormat(NSLocalizedString("new_messages", comment: ""), 2)

That's pluralisation, but what about device variation? .stringsdict has us covered:

A localised string that varies based on device.
A localised string that varies based on device.

This will vary the message based on device. By default, the message will be "Tap here", but on macOS it will be "Click here". Variations can be provided for all Apple device types, such as Apple Watch, Apple TV, iPad etc. We provide translations for these more complex localisations in the same way as we did with the simpler ones in the .strings files - we duplicate the .stringsdict files and store them in the locale-keyed subdirectories.


This concludes our brief overview of the state of string localisation on Apple platforms as it has existed since Mac OS X was introduced. It has served us well, but is not without its problems.

First of all, the primary purpose of string localisation is to support translation in to different languages. However, the process for doing this revolves around duplicating files. This presents problems for keeping these files in sync. Any new localisations need to be entered in to each file for translation - it doesn't take much to simply miss a file or not to notice when a translation hasn't been provided in one of them. As for strings with pluralised or device variants, the file format is a property list for which Xcode's editor leaves a lot to be desired. It is easy to accidentally misconfigure something and break your app's localisations for a particular language. There must be a better way than this…

Introducing String Catalogs

Xcode 15 introduces a new way to manage our string localisations, in the form of a new .xcstrings file type, which is referred to as a String Catalog. Xcode 15 also provides a simple migration process to move our old .strings and .stringsdict files over to .xcstrings files. Let's see what our examples from earlier look like when migrated to a string catalog:

Our localised strings after migration to XCStrings.
Our localised strings after migration to XCStrings.

We now have a single file for both our simple and complex localisations, and can easily see the placeholders as well as different variations based on plurals and devices.

But what about other languages? Do we need to duplicate the .xcstrings file like we did before? No! Instead, we add new languages to the same file and can see them alongside our default language strings. Let's take a look at how this works by adding French localisations to our .xcstrings file. We start by selecting the language we want to add from the + button in Xcode's editor:

Selecting the new language to be added.
Selecting the new language to be added.

Having selected this new language, we have a new set of localisations in our .xcstrings file:

The French localisations we just created.
The French localisations we just created.

Xcode has marked all of these localisations as "New" because we have not yet provided the translations for this new language. It is therefore easy for us to see which ones need updating, especially compared to simply duplicating a .strings file. We can also see that 0% of the required translations have been provided, so let's add them now:

Our localised strings file, with the French translations provided.
Our localised strings file, with the French translations provided.

Voila! We can easily see that we have provided all of the translations needed for the French version of our app. What's more, if we update any of the strings in the English version of our localisations then the corresponding strings for the other languages that our app supports will be flagged as needing review. Again, this makes it much simpler to keep localisations up to date and in sync compared to the old system.

But Wait, There's More!

The .xcstrings file editor has a few more tricks up its sleeve. Let's add a string that has not one, but two plural variations. We may want to give the user a detailed description of how many new messages they have, that also includes the number of inboxes that the messages span. For this, we will need to vary the text based on two different pluralisations - the number of messages, and the number of inboxes. Let's update our "new_messages" localisation to support this:

Updating the new_messages localisation to contain two plural placeholders.
Updating the new_messages localisation to contain two plural placeholders.

We've updated our localisation to include two numerical placeholders. If we right click on the localisation, we are given the option to provide plural variations for both of these placeholders:

Adding plural variations to placeholders.
Adding plural variations to placeholders.

By default, our placeholders are renamed to @arg1 and @arg2, but we can given them any names we like, so let's rename them to something more meaningful:

Plural variations with named substitutions.
Plural variations with named substitutions.

As you can see, we can provide specific plural variations for each part of the string. Again, we make use of this using the same API that we did before:

/// Produces You have 14 new messages in 2 inboxes
String.localizedStringWithFormat(NSLocalizedString("new_messages", comment: ""), 14, 2)

/// Produces You have 1 new message in 1 inbox
String.localizedStringWithFormat(NSLocalizedString("new_messages", comment: ""), 1, 1)

Notice also that our French localisations have now dropped to a completion rate of 80%. That is because we modified the "new_messages" localisation in the base language (English), so the corresponding French translation has now been marked so that we know it needs to be updated.

Further than this, you can even have strings varied by device, the variations of which also contain multiple plural variations with substitutions. If your copywriting team can dream it up, it can almost certainly be supported by String Catalogs.

Is There a Catch?

You may be thinking that this is too good to be true. String Catalogs are so much better than managing multiple .strings and .stringsdict files, surely there must be a fly in the ointment somewhere. But no! When Xcode builds your app with String Catalogs, it converts the catalogs in to .strings and .stringsdict files as part of the build process. This means two things - one, you don't have to change your code when migrating to String Catalogs, your existing calls to NSLocalizedString will continue to work. And two - because String Catalogs are converted to .strings and .stringsdict files automatically, there is no minimum iOS version requirement for using them! You can convert to String Catalogs as soon as you start using Xcode 15, without affecting your app's minimum deployment target. Nice!

Conclusion

String Catalogs represent a great improvement in how we manage the localisation process for our apps. We no longer have to manage a proliferation of files, and can easily see when our translations are complete and up to date. As soon as you can switch to Xcode 15, give String Catalogs a go, and make life easier for yourself and your localisation team.