Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every two weeks.
Paid subscribers unlock Quick Hacks, my advanced tips series, and enjoy exclusive early access to my long-form articles.
String Catalogs are a neat new feature introduced in WWDC 2023.
.strings
files (and, to an even greater extent, .stringsdict
files) were one of the final bastions of Objective-C-era cruft in the Apple development environment.
But no more.
I’ve been upgrading Bev, my trusty, boozy side project, to iOS 17. Along the way, I’ve been implementing the teachings from WWDC23’s Discover String Catalogs.
Join me on the journey!
The Bad Old World™
Prior to Xcode 15, most of your strings will be defined in a .strings
file:
// Home screen
"home_screen_title"="Home";
"home_screen_button_add_friend"="Add friend";
"home_screen_button_settings"="Settings";
"home_screen_friends_list"="Your friends list";
You missed one semicolon?
Too bad, every string in your module is now broken, displaying home_screen_title
or home_screen_button_add_friend
in place of your actual copy.
.stringsdict
files are even tougher to grasp. Because different languages have different grammatical rules for handling pluralisation, we need a far more complex beast to define a single string, even to say something as simple as “3 friends”:
<dict>
<key>home_screen_friends_count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@friends_count@</string>
<key>friends_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string>No friends</string>
<key>one</key>
<string>1 friend</string>
<key>other</key>
<string>%d friends</string>
</dict>
</dict>
</dict>
Creating a String Catalog
When I first wrote Bev, I didn't bother internationalising the strings.
😱
We could argue all day that the API only returns data in English, or that I was never intending to publish the app to the 175 countries reachable on the App Store.
But frankly, it was just sloppy work.
Shame on me.
On the plus side, however, this gives us a clean slate to start from.
What sort of text exists in the app?
The Bev app contains 2 screens:
The list of beers
The details for a specific beer.
There are a few types of UI components — and a few types of strings — which we will want to internationalize.
// Simple standalone strings
//
NavigationStack {
beerListView
.navigationTitle("Bev")
}
Alert(title: Text("Error"), /* ... */ )
Section("Drink this with") { /* ... */ }
// Strings with variable text inside
//
Text("First brewed \(beer.firstBrewed)") // e.g. "First brewed 2020"
// Strings with formatted numbers
//
Text(String(format: "%.2f", beer.abv) + "% abv") // e.g."4.05% abv"
// Pluralised strings
//
Text("\(viewModel.beers.count) beers") // e.g. "100 beers"
The String Catalog
First things first.
Let’s create a new file in our Resources/
folder, and select the new String Catalog file type to add to our app target.
Initially, however, the String Catalog is fairly nondescript. What’s all the fuss about?
The magic happens when we run our code, and Xcode populates this file at compile-time. In an older project, this might not happen automatically — make sure to set Use Compiler to Extract Swift Strings in Xcode Build Settings.
Wow! 🤩
Suddenly, all my raw strings are vacuumed up into our String Catalog, the Localizable
file.
For the interested — this
.xcstrings
file is actually implemented as JSON if you simply open the file in a text editor. At build-time, Xcode actually compiles the String Catalog down into.strings
and.stringsdict
files in order to maintain full backwards compatibility.
Implicit LocalizedStringKeys
The reason this “magic” happens is, SwiftUI primitives implicitly look up a LocalizedStringKey
whenever they are provided a string literal. These implicit LocalizedStringKey
s are extracted by the compiler to populate our String Catalog at Localizable.xcstrings
.
From Apple’s docs: This implicit lookup actually works because
LocalizedStringKey
conforms to theExpressibleByStringLiteral
protocol.
Internationalising Bev
Now we’ve learned some theory, and populated our initial String Catalog, we can finally internationalise the various strings in the app.
Simple standalone strings
These are our simplest case; where there is a simple piece of text in a SwiftUI primitive, such as:
Section("Yeast")
The LocalizedStringKey
is implicitly generated, so our String Catalog already works:
However, it’s good practice to organise our string keys by screen and feature, so I namespace them like Section.FeatureName.Screen.String
:
Section("Feature.Beer.DetailView.Yeast")
It’s also very polite to add context to the String Catalog for our future translators:
Strings with variable text inside
Very often, we will want to display text which involves string interpolation based on a variable or a property.
Text("First brewed \(beer.firstBrewed)")
It’s pretty clear that this might not work in other languages, even if the text was translated — in the grammar of many languages, we may want to put the year first.
The String Catalog cleverly accounts for this.
We first re-write our implicit LocalizedStringKey
to this more exact form, along with an argument that we inline via string interpolation:
Text("Feature.Beer.DetailView.FirstBrewed \(beer.firstBrewed)")
The String Catalog generates the string including the interpolated value, using %@
as the placeholder:
Strings with formatted numbers
Similar to string interpolation, we will also often want to place numbers inside our strings.
Displaying the alcohol content of a beer is a common use case for this — I’m sure most of us do this in all our apps.
Text("\(String(format: "%.2f", beer.abv))% abv")
The LocalizedStringKey
we get in our String Catalog is pretty confusing here:
This looks a bit crazy because we’re using the escaped interpolation symbol, %@
, in conjunction with an actual %
as part of the string. This has to be escaped with — you guessed it — another %
symbol.
This synthesised version is problematic, however, since we are offloading string decimal formatting logic to our SwiftUI view primitive with String(format: “%.2f”, beer.abv)
.
Fortunately, String Catalogs allow us to put this in its proper place. First, again, we create our implicitLocalizedStringKey
:
Text("Feature.Beer.DetailView.ABV \(beer.abv)")
Next, we can build the code and edit the String Catalog’s output. The compiler resolves the type of the Beer
struct’s abv
property, so already shows the Double
-typed interpolated string as %lf
. Now, we set the 2-decimal-places format we want in the String Catalog with %.2f
.
Pluralised strings
This is the real clincher.
.stringsdict
files are a pain to manage at the best of times, and so the killer app of the String Catalog is burying this file at last.
If you’re not sure why they exist in the first place, this WWDC slide explains better than I could:
I upgraded Bev with a search bar, which reads something like “Search 325 beers”. Were you very naïvely implementing this, you might simply write:
.searchable(text: $searchText,
placement: .navigationBarDrawer,
prompt: "Search \(viewModel.beers.count) beers")
If you being somewhat more thoughtful, but had never heard of a .stringsdict
file (which I embarrassingly had not until 2022), you might write something like this:
.searchable(text: $searchText,
placement: .navigationBarDrawer,
prompt: "Search \(viewModel.beers.count) beer\(viewModel.beers.count == 1 ? "" : "s")")
This works fine if you’re just using English, but is objectively pretty ugly (and inefficient!) code.
Going back to the initial naïve version, here’s what our String Catalog produces at compile-time. We can right-click the row and select Vary by Plural.
Now, the string simply splits into its pluralisation options, based on our langauge — in English, it’s just “One” and “Other”. You don’t need me to tell you what to do next.
This is far easier than a terrifying dictionary, or the crime against software I started with!
Finally, of course, we can update the string key to fit our organisational system:
.searchable(text: $searchText,
placement: .navigationBarDrawer,
prompt: "Feature.Beer.ListView.SearchBarPrompt \(viewModel.beers.count)")
As well as varying by plural, you might have noticed you also have the option to vary strings by device. This allows you to use different strings across platforms such as iPhone, Mac, or even the Vision Pro!
Strings in your View Model
As a final point, there are times you might not be setting strings directly in your views as an implicit LocalizedStringKey
— not everything is going to be inside a SwiftUI primitive, and not every string populating a primitive will be a string literal (especially anything wrapped in a ForEach
).
Apple recommends we use LocalizedStringResource
whenever we want to do this — be warned though, that this only exists for iOS 16+.
Here, we can simply instantiate a LocalizedStringResource
with a string literal in our view model:
private let string = LocalizedStringResource("Feature.Beer.List.String")
And it’s populated into our String Catalog the same as the rest of the keys!
This is the preferred approach because the LocalizedStringResource
initializer also supports comments, table names, and default values for the string.
Translating Bev
What article on localization (l10n) would be complete without actually localizing my application?
Let me be clear — anyone who wrote about localization via String Catalogues without doing this is a charlatan since they were merely practicing internationalisation (i18n).
When talking about languages, semantics is important, dammit!
Utilising the raw JSON of my String Catalog, and with the help of my good friend at OpenAI, I’ll (*cringe*) prompt-engineer my way to a translated app.
“Here is a JSON file which produces a brand-new Xcode format for String Localizations. Using the *exact* same syntax and structure, please update this JSON file so that we also contain French, German, and Spanish translations of each string. Do *not* miss a single line.”
You can’t argue with the results.
French
Spanish
German
Conclusion
I hope I’ve helped to inspire you.
The pain of managing a whole bunch of boilerplate for your string resources has been curtailed.
Perhaps, if I dare to dream, now we can join our brethren on Android who have been pretty good with localization for years.
p.s. please do not consider laying off your localization team to replace them with AI translations, I frankly have no idea whether trinken sie dies mit is a hallucination or not.
Thank you for reading Jacob’s Tech Tavern 🍺