195 lines
9.7 KiB
Markdown
195 lines
9.7 KiB
Markdown
# SwiftUI Localization Reference
|
||
|
||
Guidance for user-facing text: `Text`, `Button`, `Label`, navigation/toolbar titles, alerts, and types that carry localizable strings. For the narrower "verbatim vs localized" decision on a single `Text`, see `references/text-patterns.md`.
|
||
|
||
## Table of Contents
|
||
|
||
- [SwiftUI Localizes String Literals Automatically](#swiftui-localizes-string-literals-automatically)
|
||
- [String Catalogs](#string-catalogs)
|
||
- [Bundle for Swift Packages and Frameworks](#bundle-for-swift-packages-and-frameworks)
|
||
- [Localizing Variables and Custom Types](#localizing-variables-and-custom-types)
|
||
- [LocalizedStringResource for Non-View Types](#localizedstringresource-for-non-view-types)
|
||
- [Interpolation vs Concatenation](#interpolation-vs-concatenation)
|
||
- [Casing](#casing)
|
||
- [Formatting Dates, Numbers, and Currencies](#formatting-dates-numbers-and-currencies)
|
||
- [Layout for Localization](#layout-for-localization)
|
||
- [Reading the Current Locale](#reading-the-current-locale)
|
||
- [String(localized:) Outside SwiftUI Views](#stringlocalized-outside-swiftui-views)
|
||
- [Comments for Translators](#comments-for-translators)
|
||
|
||
## SwiftUI Localizes String Literals Automatically
|
||
|
||
Initializers that accept `LocalizedStringKey` (`Text`, `Button`, `Label`, `.navigationTitle`, alert titles, and so on) treat string literals as localization keys automatically. Do not wrap literals in `NSLocalizedString`, `String(localized:)`, or `LocalizedStringResource` — that resolves the string eagerly and ignores `\.locale` overrides.
|
||
|
||
```swift
|
||
// AVOID: double work, and resolves eagerly
|
||
Text(String(localized: "start_workout"))
|
||
|
||
// PREFER: pass the literal directly
|
||
Text("start_workout")
|
||
```
|
||
|
||
Both opaque keys (`"start_workout"`) and natural-language strings (`"Start Workout"`) work as keys — pick whichever convention the project already uses. Use `Text(verbatim:)` only to opt a literal out of localization (e.g. a debug label interpolating a runtime value). When the argument is already a `String` variable, `Text(value)` calls the `StringProtocol` overload and skips localization on its own.
|
||
|
||
## String Catalogs
|
||
|
||
Most projects localize through String Catalogs (`.xcstrings`). Each build syncs new keys from code into the catalog, but the catalog file must already exist — Xcode doesn't create one automatically. If a project already uses `.strings` / `.stringsdict`, add new strings there rather than migrating. Route groups of strings to a specific catalog with `tableName:`.
|
||
|
||
```swift
|
||
Text("Explore", tableName: "Navigation",
|
||
comment: "Tab bar item title for the Explore screen.")
|
||
```
|
||
|
||
## Bundle for Swift Packages and Frameworks
|
||
|
||
Apps, app extensions, and XPC services are their own main bundle, so `bundle` can be omitted. Frameworks and Swift packages need an explicit `bundle:` — without one, SwiftUI looks up strings in `Bundle.main`, the lookup fails silently, and the string appears unlocalized at runtime.
|
||
|
||
```swift
|
||
// AVOID (inside a framework/package): searches the app's catalog
|
||
Text("Save to Favorites")
|
||
|
||
// PREFER: #bundle resolves to the current target's bundle
|
||
Text("Save to Favorites", bundle: #bundle,
|
||
comment: "Button to bookmark a recipe.")
|
||
```
|
||
|
||
`#bundle` is the preferred form; `Bundle.module` and `Bundle(for:)` still work but are older patterns.
|
||
|
||
## Localizing Variables and Custom Types
|
||
|
||
A `String` variable passed to `Text` runs the `StringProtocol` overload and is **not** localized. Wrapping it in `LocalizedStringKey(_:)` doesn't help — Xcode can't extract a literal from a runtime value, so nothing lands in the catalog. To localize a value chosen from a known set, model it with a type that exposes `LocalizedStringResource`:
|
||
|
||
```swift
|
||
enum Category {
|
||
case appetizers, mains, desserts
|
||
var name: LocalizedStringResource {
|
||
switch self {
|
||
case .appetizers: "Appetizers"
|
||
case .mains: "Mains"
|
||
case .desserts: "Desserts"
|
||
}
|
||
}
|
||
}
|
||
|
||
Text(category.name)
|
||
```
|
||
|
||
When a view or model exposes user-facing text, type the property as `LocalizedStringKey` or `LocalizedStringResource` rather than `String`. Every SwiftUI view that takes localized text accepts both, so deferring resolution costs nothing at the display site and preserves locale/bundle context.
|
||
|
||
## LocalizedStringResource for Non-View Types
|
||
|
||
When a non-view type carries user-facing text — a model object, a tip, a queued notification — use `LocalizedStringResource` instead of `String`. It defers resolution to display time, so it honors the locale active when the value actually renders, not when it was created.
|
||
|
||
```swift
|
||
// AVOID: resolved at creation time, can't re-render in another locale
|
||
struct Tip { let headline: String }
|
||
let tip = Tip(headline: String(localized: "Tip of the Day"))
|
||
|
||
// PREFER: resolution deferred to display time
|
||
struct Tip { let headline: LocalizedStringResource }
|
||
let tip = Tip(headline: "Tip of the Day")
|
||
```
|
||
|
||
Apply this when designing new types or changing user-facing text — don't sweep through existing `String` properties as part of unrelated edits.
|
||
|
||
## Interpolation vs Concatenation
|
||
|
||
String interpolation preserves `LocalizedStringKey` and produces a format string in the catalog (e.g. `"Welcome, %@"`). Concatenation with `+` produces a plain `String` and is not localized. Never glue separately localized fragments into a sentence — word order varies across languages.
|
||
|
||
```swift
|
||
// AVOID: + produces String; sentence assembly breaks word order
|
||
Text("Error: " + statusMessage)
|
||
Text(String(localized: "Created by")) + Text(" ") + Text(authorName)
|
||
|
||
// PREFER: one interpolated string translators can rearrange
|
||
Text("Error: \(statusMessage)")
|
||
Text("Created by \(authorName)")
|
||
```
|
||
|
||
## Casing
|
||
|
||
Bake the desired case into the string rather than transforming at runtime via `.textCase(_:)`, `.localizedUppercase`, or `.localizedCapitalized`. A runtime transform forces the same casing on every translation, leaving translators no room to adjust per language.
|
||
|
||
```swift
|
||
// AVOID
|
||
Text("Section Header").textCase(.uppercase)
|
||
|
||
// PREFER
|
||
Text("SECTION HEADER")
|
||
```
|
||
|
||
This applies to localized strings; display user-entered text as-is. If a transform is unavoidable, prefer `.localizedUppercase` / `.localizedCapitalized`, which honor the user's locale.
|
||
|
||
## Formatting Dates, Numbers, and Currencies
|
||
|
||
Use `Text`'s `format:` parameter or `.formatted()` instead of `DateFormatter` / `NumberFormatter` with hardcoded format strings. Format styles adapt to the user's locale; hardcoded format strings don't.
|
||
|
||
```swift
|
||
// AVOID
|
||
let f = DateFormatter(); f.dateFormat = "MM/dd/yyyy"
|
||
Text(f.string(from: workout.date))
|
||
Text("$\(product.price, specifier: "%.2f")")
|
||
|
||
// PREFER
|
||
Text(workout.date, format: .dateTime.month().day().year())
|
||
Text(product.price, format: .currency(code: store.currencyCode))
|
||
```
|
||
|
||
Field components (`.month()`, `.day()`) choose which fields appear; the locale decides the order. For lists, `Array.formatted()` inserts locale-correct separators and conjunctions instead of `joined(separator:)`. When `DateFormatter` is genuinely unavoidable, use `setLocalizedDateFormatFromTemplate(_:)` rather than assigning `dateFormat`.
|
||
|
||
## Layout for Localization
|
||
|
||
- Use `.leading` / `.trailing` instead of `.left` / `.right` — they flip for right-to-left locales.
|
||
- Don't hardcode frame widths/heights for text; translations vary in length and scripts vary in height. Use `ViewThatFits` when a layout might not fit longer translations.
|
||
- Use text styles (`.body`, `.headline`) rather than fixed point sizes, so line height adapts per script.
|
||
|
||
```swift
|
||
// PREFER
|
||
Text(recipe.title)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
ViewThatFits {
|
||
HStack { actionButtons }
|
||
VStack { actionButtons }
|
||
}
|
||
```
|
||
|
||
## Reading the Current Locale
|
||
|
||
Use `@Environment(\.locale)` for locale-dependent logic in views, not `Locale.current` — the environment respects preview overrides and per-view injection.
|
||
|
||
## String(localized:) Outside SwiftUI Views
|
||
|
||
When you need a localized `String` outside a view, use `String(localized:)`, not `NSLocalizedString`. Don't interpolate inside `NSLocalizedString` — Xcode extracts keys from literals at build time and can't extract interpolated values. `String(localized:)` supports interpolation (it extracts the format string and treats values as runtime arguments) and is preferred over `String(format:)`, which always renders digits as 0–9 regardless of locale.
|
||
|
||
```swift
|
||
// PREFER
|
||
let title = String(localized: "activity_summary", comment: "Dashboard header")
|
||
```
|
||
|
||
## Comments for Translators
|
||
|
||
Add a `comment:` describing the UI element and its purpose, especially for ambiguous strings. For interpolated strings, describe each placeholder by position — translators don't see Swift variable names. Comments can live at the call site or in the String Catalog's per-string Comment field; keep one source of truth per string.
|
||
|
||
```swift
|
||
// AVOID: "Edit" could be a noun or a verb
|
||
Text("Edit")
|
||
|
||
// PREFER
|
||
Text("Edit", comment: "Toolbar button that enters editing mode for the list.")
|
||
Text("Completed \(count) of \(total)",
|
||
comment: "Progress label — first variable is finished items, second is the total.")
|
||
```
|
||
|
||
## Summary Checklist
|
||
|
||
- [ ] String literals passed directly to `Text`/`Button`/`Label` (not wrapped in `NSLocalizedString`/`String(localized:)`)
|
||
- [ ] `bundle: #bundle` on user-facing strings inside frameworks and Swift packages
|
||
- [ ] User-facing text on models/non-view types typed as `LocalizedStringResource`, not `String`
|
||
- [ ] Interpolation (not `+`) for dynamic strings; no sentence assembly from fragments
|
||
- [ ] Case baked into the string, not applied via `.textCase`
|
||
- [ ] Dates/numbers/currencies use `format:` / `.formatted()` with locale-aware styles
|
||
- [ ] `.leading`/`.trailing` (not `.left`/`.right`); no hardcoded text frame sizes
|
||
- [ ] `@Environment(\.locale)` for locale logic in views
|
||
- [ ] `comment:` provided for ambiguous strings and interpolated placeholders
|