Internationalization
String Catalog, runtime language switching, and adding a locale.
The app ships in English and Simplified Chinese and can switch language
live, without a relaunch — a capability plain SwiftUI localization doesn't
give you for free. This page explains the String Catalog source of truth, the
LocaleController that makes in-session switching work, the one call-site rule
you must follow, and how to add a language.
Single source of truth: the String Catalog
Every user-facing string lives in Localizable.xcstrings (a String
Catalog), with sourceLanguage: "en" and a zh-Hans translation per key. Keys
are dotted and namespaced by feature:
auth.alreadyHaveAccount
todos.filterActive
tabs.home
profile.systemXcode compiles the catalog into per-locale .lproj bundles at build time. Add
a key by editing the catalog in Xcode's String Catalog editor (or the raw JSON)
and filling in both languages.
Don't scatter free-form String(localized:) literals in feature code. Add
the key to Localizable.xcstrings first, then read it through
locale.t("your.key"). Un-cataloged literals won't get a translation and
won't react to the in-app language switch.
Live switching: LocaleController
The reason the language flips instantly is LocaleController, an @Observable
that owns the selected language and resolves the right resource bundle at read
time:
@Observable
final class LocaleController {
var selectedLanguage: AppLanguage // .system | .english | .chineseSimplified
var effectiveLocale: Locale { selectedLanguage.locale }
var localizedBundle: Bundle {
guard
let code = selectedLanguage.resourceCode, // "en" | "zh-Hans"
let path = Bundle.main.path(forResource: code, ofType: "lproj"),
let bundle = Bundle(path: path)
else { return .main }
return bundle
}
}Because the controller is @Observable, any view body that calls into it
re-renders when selectedLanguage changes. The t(_:) helper routes every
lookup through the currently-selected bundle and locale:
extension LocaleController {
func t(_ key: String.LocalizationValue) -> String {
String(localized: key, bundle: localizedBundle, locale: effectiveLocale)
}
}The one rule: always go through locale.t(...)
// ✅ Reacts to the in-app language switch
Text(locale.t("tabs.home"))
// ❌ Binds to Bundle.main + the launch-time locale — won't switch live
Text("tabs.home")The bare LocalizedStringKey form (Text("tabs.home")) resolves against
Bundle.main and the locale fixed at launch, so it ignores an in-session change.
Read LocaleController from the environment and route through t(...):
@Environment(LocaleController.self) private var locale
// …
Text(locale.t("todos.filterActive"))For SwiftUI surfaces that read the locale from the environment rather than a
string key — Date.FormatStyle, number formatting, Text(_, format:) —
RootView mounts .environment(\.locale, locale.effectiveLocale), so those
honor the override automatically with no per-call threading.
The AppleLanguages fallback
When you pick a non-system language, LocaleController also writes
UserDefaults["AppleLanguages"]. This is belt-and-braces, not the live
mechanism: it only takes effect on the next cold launch and exists solely for
system-rendered surfaces SwiftUI can't reach through a bundle — keyboard
suggestions, the share sheet, certain system alerts. The in-app UI switches
immediately via the bundle above; these stragglers catch up next launch.
Adding a language
Add the locale to the catalog. In Xcode, select Localizable.xcstrings →
the + in the language list → e.g. Spanish (es). Translate each key
(untranslated keys fall back to the source language).
Add a case to AppLanguage (Core/Locale/AppLanguage.swift) and fill in
all four switches — the resourceCode must exactly match the .lproj folder
Xcode emits ("es"), plus locale, displayName, and id:
case spanish
// resourceCode → "es"
// locale → Locale(identifier: "es")
// displayName → "Español"Build and verify. The new language appears in the picker (backed by
AppLanguage.allCases). Select it and confirm the UI re-renders in place — no
relaunch — and that Date/number formatting follows.
resourceCode is the subtle one: it must be the exact folder name Xcode
generates for that translation (zh-Hans, not zh). If localizedBundle
can't find the .lproj, it silently falls back to Bundle.main and the
language won't switch live.
Where the language is stored
The choice persists in AppPreferences.languageTag (a UserDefaults key).
.system stores nil and defers to the device locale. Language and
theme live side by side in AppPreferences, and the
Me tab exposes both pickers.
