
Eleven Languages, One Toggle
Nobody ships 11 languages on day one. I did it anyway.
Most apps add localization as an afterthought. The product ships in English, users request other languages, a sprint gets scheduled, and then someone spends two weeks hunting down hardcoded strings buried in view files. Every string found is one that slipped past review. Most strings are not found until a native speaker files a bug. This is the localization version of “we’ll write tests later.”
I decided to do it from the start, before the first public build. Internationalization (i18n) and localization (l10n) were design constraints from day one, the same way accessibility was. The argument is simple: adding localization after the fact means doing the work twice. You find the strings, you extract them, you verify nothing is broken.
The string system
Most localized apps require a restart to switch languages. The user changes a setting, quits, relaunches, and hopes the UI updated. Renala switches instantly. Pick a language in preferences, the UI updates in place. No restart, no stale state.
The mechanism behind this: Swift’s String(localized:bundle:) takes a Bundle parameter. Pass a custom Bundle loaded for the target language, and strings resolve from that bundle’s localization table instead of the system default. The standard String(localized:) always resolves from the main bundle using the system language. No bundle parameter, no language override. The UI stays in whatever language macOS is set to, regardless of what the user selected.
LocalizationManager
The live switching mechanism lives in LocalizationManager, an @Observable class isolated to @MainActor. When the user changes language, it loads a new Bundle and updates a published property. Every view that reads from LocalizationManager re-renders automatically via SwiftUI’s observation tracking. No restart, no window refresh, no manual invalidation.
The pattern in views: read the manager from the environment, call String(localized:bundle:) with the manager’s active bundle. Observation handles the rest.
Non-view code uses LocalizationManager.shared, the singleton on @MainActor. Menu items, accessibility strings, anything outside the view hierarchy. VoiceOver reads accessibility labels aloud, so “Scan complete, 3.2 GB analyzed” needs to be correct in Arabic, not just English.
flowchart TD
A["User selects language<br/>in Preferences"] --> B["LocalizationManager<br/>.currentLanguage changes"]
B --> C["New Bundle loaded<br/>for target language"]
C --> D["@Observable propagates<br/>change to all views"]
D --> E["Views re-render with<br/>String(localized:bundle:)"]
E --> F["UI reflects new locale<br/>no restart required"]
xcstrings
Apple introduced .xcstrings as the modern replacement for .strings files. The format is JSON-based. One file holds all languages for a given string table. This is a significant improvement over the old approach, where each language lived in a separate .lproj directory and keeping them in sync was manual and error-prone.
With .xcstrings, adding a new string means adding one entry to one file. Xcode’s editor shows all translations side by side. Missing translations are flagged. The file diffs cleanly in version control.
The translation pipeline
The .xcstrings files are not edited directly. They are generated.
Scripts/Localization/generate_translations.py is the source of truth. It holds every string and its translation in all 11 languages. Running it regenerates the .xcstrings files. This keeps the translation data in a format that is easy to read, diff, and edit without Xcode. It also means the translation process can be scripted: update the Python file, run the generator, commit the result.
flowchart LR
PY["generate_translations.py<br/>(source of truth)"] -->|"python3"| XC[".xcstrings files<br/>(generated output)"]
XC --> APP["App bundle<br/>localization tables"]
PY -->|"lint"| LINT["lint_translations.py<br/>key consistency"]
PY -->|"pseudo"| PSEUDO["pseudo_localization_check.py<br/>placeholder integrity"]
PY -->|"completeness"| COMP["check_completeness.py<br/>coverage per language"]
Adding a new user-facing string means adding it to the Python script first. Modifying a string means updating the script and all affected translations. The .xcstrings files are outputs, not inputs.
RTL and Arabic
Arabic is a right-to-left language. When the active language is Arabic, the entire UI layout mirrors: navigation goes right to left, chevrons point the opposite direction, text alignment flips.
SwiftUI handles most of this automatically through the layoutDirection environment. The part that will bite you is icon direction. chevron.left for back-navigation? Wrong in Arabic. The reading direction is reversed, so “back” points right.
The fix: chevron.forward and chevron.backward. Semantic, not directional. SwiftUI mirrors them automatically based on layout direction. Get this right and Arabic users see correctly-pointing navigation arrows. Get it wrong and your app is subtly broken for 400 million native speakers, none of whom will file a bug report in a language you can read.
graph LR
subgraph LTR["LTR (English, French, ...)"]
L1["← back"] --- L2["content"] --- L3["forward →"]
end
subgraph RTL["RTL (Arabic)"]
R1["forward ←"] --- R2["محتوى"] --- R3["back →"]
end
LTR -.->|"chevron.forward / .backward<br/>auto-mirrors"| RTL
Arabic plural forms
English has two plural categories: one item, and everything else. Arabic has six: zero, one, two, few, many, other. These are the CLDR categories, and they are not decorative.
“1 file” and “2 files” are different words in Arabic. So are “11 files” and “100 files.” The rules are specific, internally consistent, and will make an English speaker’s head spin.
xcstrings supports all six CLDR categories per language. Populate all six for Arabic strings that involve counts. Collapse them into two and the Arabic is grammatically wrong for most numbers. This is the kind of mistake that is invisible to non-Arabic speakers in review and immediately obvious to every Arabic speaker who opens the app.
The implementation uses integer interpolation rather than .formatted() for count strings. .formatted() applies locale-specific number formatting which can produce unexpected results for plural rule selection. An integer passed directly lets the plural rule machinery work correctly.
The 11 supported languages and their CLDR plural category counts:
| Language | Script | Plural categories |
|---|---|---|
| English | Latin | 2 |
| French | Latin | 2 |
| German | Latin | 2 |
| Spanish | Latin | 2 |
| Italian | Latin | 2 |
| Portuguese | Latin | 2 |
| Dutch | Latin | 2 |
| Japanese | Hiragana/Katakana/Kanji | 1 |
| Chinese Simplified | Han | 1 |
| Arabic | Arabic | 6 |
| Korean | Hangul | 1 |
Arabic’s six categories (zero, one, two, few, many, other) are why it requires the most attention in the translation pipeline. Every count string needs six distinct forms, not two.
CI checks
Three checks run on every commit that touches localization files.
The translation lint checks key consistency: every key present in the English table must exist in all other language tables. Keys that exist in a translation but not in English are flagged as orphans.
The pseudo-localization check verifies placeholder preservation. A string like “Scanning %d files” has a format placeholder. The translated version must preserve the %d. A translation that drops or misplaces a placeholder will crash at runtime when the string is formatted.
The completeness check counts coverage: what percentage of keys have translations in each language. A language that falls below a threshold blocks the build.
flowchart TD
COMMIT["git push"] --> CI["GitHub Actions"]
CI --> LINT["Translation lint<br/>key consistency"]
CI --> PSEUDO["Pseudo-localization<br/>placeholder check"]
CI --> COMP["Completeness check<br/>coverage threshold"]
LINT -->|"orphan key?"| FAIL["Build fails"]
PSEUDO -->|"missing %d?"| FAIL
COMP -->|"< threshold?"| FAIL
LINT & PSEUDO & COMP -->|"all pass"| PASS["Build succeeds"]
These checks run in CI via GitHub Actions. They catch the common failure modes before they reach production: missing keys, broken placeholders, incomplete languages.
What it costs
Every user-facing string gets written twice: once in English in the code, once in the translation script with all 11 translations. The discipline is real. There is no shortcut where you write English and add translations later.
The payoff: the app launched with Arabic and Japanese working correctly, including RTL layout and six-category plural forms. No string hunting, no untranslated fallback text, no post-launch localization sprint.
The infrastructure is the real investment. Once the pipeline exists, adding a new string costs a few minutes. Adding a new language costs a few hours. The best localization work is the work you front-load. If you are building a Mac or iOS app and you think localization can wait: the longer you wait, the more strings you bury in views, the harder the migration becomes.
References
- Apple: Localizing and varying text with a String Catalog: xcstrings format and Xcode editor documentation.
- WWDC 2023: Discover String Catalogs: Introduction to the xcstrings format and migration from .strings files.
- Unicode CLDR plural rules: Specification for all CLDR plural categories, including Arabic’s six forms.
- Apple HIG: Localization: Human Interface Guidelines on localizing Mac and iOS apps.
- Supporting right-to-left languages in SwiftUI: Layout direction, mirroring, and RTL-aware symbol usage.