Engineering SwiftUI Localization with String Catalogs
SwiftUI localization looks wonderfully simple on its first day. Put Text("Settings") in a view, build the project, and Xcode can extract the text into a String Catalog. The hard problems arrive after the second language ships: runtime strings never enter the catalog, concatenation breaks plural grammar, a Swift Package keeps displaying English, a long German button destroys the layout, and tests cover only the developer’s language.
The solution is not a larger collection of NSLocalizedString calls. It is an explicit contract for when user-facing text is resolved, which bundle owns it, and which locale formats it.
Pass literals directly to SwiftUI
SwiftUI APIs that accept LocalizedStringKey treat string literals as localization keys. This includes Text, Button, Label, navigation titles, and alert titles:
Button("Save changes") {
save()
}
Text("Welcome back",
comment: "Greeting shown above the account dashboard.")
Do not wrap a literal in String(localized:) before passing it to Text. That resolves the value early and prevents the view’s locale environment from doing its job. Use Text(verbatim:) only for text that intentionally must not be translated, such as a debug identifier or a fixed protocol value.
Create the .xcstrings file before expecting extraction; Xcode does not create a catalog automatically. Each build then scans localizable APIs and synchronizes new keys into the catalog. Natural-language keys and opaque keys such as settings.save_button both work. Choose the convention that matches the existing project and apply it consistently.
A String variable is not localized automatically
The following code looks similar to a literal but calls the plain String overload:
let title: String = feature.title
Text(title) // Displayed directly; no String Catalog lookup
When a value comes from a known set of business states, do not store localization keys in runtime strings. Let the model expose a LocalizedStringResource and defer resolution until display time:
enum SyncState {
case idle, syncing, complete
var title: LocalizedStringResource {
switch self {
case .idle: "Ready to sync"
case .syncing: "Syncing"
case .complete: "Sync complete"
}
}
}
Text(state.title)
Xcode can discover every branch during the build, and the model does not cache a String that was already resolved for an earlier locale.
Interpolate instead of assembling sentences
Languages use different word orders, so a sentence cannot safely be assembled from separately translated fragments:
// Avoid: the localizer cannot reorder the complete sentence
Text("Created by " + author.name)
// Prefer: the catalog receives one format string with a placeholder
Text("Created by \(author.name)")
Interpolation preserves a localizable format that lets translators move the argument. Add a comment to text with multiple placeholders and explain each value’s meaning. Translation tools should not be expected to expose Swift variable names.
Do not encode plurals as count == 1 ? ... : ... either. First pass the complete interpolated string through a localizable API, then enable Vary by Plural in the String Catalog. English may use one and other, while another language can require more categories. Grammar does not belong in a binary branch in application code.
Let the locale format dates, numbers, and currency
A hard-coded MM/dd/yyyy pattern or a currency symbol concatenated to a number imposes one region’s conventions on every user. Prefer FormatStyle:
Text(order.date, format: .dateTime.year().month().day())
Text(order.total, format: .currency(code: order.currencyCode))
Text(progress, format: .percent.precision(.fractionLength(0)))
The code chooses which fields to show. The locale chooses their order, separators, digits, and currency presentation. Use collection formatted() APIs for human-readable lists instead of a fixed joined(separator: ", "), which cannot adapt its conjunction or punctuation.
Packages and frameworks must identify their resource bundle
An application target can rely on its main bundle. A Swift Package or framework owns different localization resources. Text inside a reusable component should explicitly use the module’s bundle:
Text(
"Save to Favorites",
bundle: #bundle,
comment: "Button that bookmarks an item."
)
A missing bundle usually does not crash. It quietly falls back to the key, which makes the defect easy to miss when testing only the source language. Give each reusable module its own catalog and verify it in a sample app with another language selected.
Layout must allow language to change size and direction
A short English button can become a long German phrase. Arabic introduces right-to-left layout. Chinese and Latin scripts do not share identical line metrics. These are normal inputs, not exceptional cases. Use .leading and .trailing, avoid fixed text widths and heights, and let actions wrap or switch layout when space is limited:
ViewThatFits {
HStack { actionButtons }
VStack { actionButtons }
}
Use semantic text styles rather than fixed point sizes. Check the mirroring behavior of icons whose meaning has direction, and do not encode visual order with left and right assumptions.
Put a language matrix in previews and CI
Injecting a locale in a preview quickly reveals clipping and hard-coded text:
#Preview("German") {
CheckoutView(order: .preview)
.environment(\.locale, Locale(identifier: "de"))
.environment(\.layoutDirection, .leftToRight)
}
#Preview("Arabic") {
CheckoutView(order: .preview)
.environment(\.locale, Locale(identifier: "ar"))
.environment(\.layoutDirection, .rightToLeft)
}
Previews are not the complete test. CI should build the catalogs, detect missing translations, and run screenshot or UI tests for at least one expansion-prone language and one right-to-left language on critical screens. When working with a localization team, export XLIFF from Xcode or with xcodebuild -exportLocalizations, then merge the returned translations into the project.
A release checklist for every user-facing string
Before shipping new copy, verify that:
- Literals go directly into SwiftUI APIs that support localization.
- Translatable model values use
LocalizedStringResource, not runtime key strings. - Dynamic sentences use interpolation and plurals live in the catalog.
- Dates, numbers, and currencies use locale-aware FormatStyle.
- Package text names the correct resource bundle.
- Translator comments explain ambiguity and placeholders.
- Long text, right-to-left layout, and Dynamic Type do not break the screen.
- The release build has no stale or untranslated keys.
A String Catalog centralizes localization data, but it cannot repair an incorrect data type, fragmented sentence, or rigid layout. Delay resolution until the UI, preserve whole sentences, make resource ownership explicit, and test multiple languages as real product configurations. That turns SwiftUI localization from “some strings were translated” into a reliable application capability.