Fixing the Czech plurals turned up a trap worth recording: String(localized: locale:) reads the language from the bundle, while locale: only drives number and date formatting. A test that passes a Czech locale and nothing else quietly compares English strings — ours failed with "1 coupon" == "1 kupón", which reads as broken plurals when the plurals were fine and the test was looking in the wrong place. Also that a plural variation must reference the number, so a key extracted as "%@" cannot carry one and the build says so outright. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
78 lines
3.1 KiB
Markdown
78 lines
3.1 KiB
Markdown
# Localization
|
||
|
||
## Plurals: inflection is not enough for Czech
|
||
|
||
**What it is.** SwiftUI parses an inflection attribute inside a `Text`
|
||
literal and applies Foundation's grammar agreement, so the noun follows
|
||
the number without any manual branching:
|
||
|
||
```swift
|
||
Text("You read ^[\(bookCount) book](inflect: true) this year!")
|
||
```
|
||
|
||
**Language support is the catch.** It shipped for English and Spanish in
|
||
iOS 15, and iOS 18 added German, French, Italian, Portuguese, Hindi and
|
||
Korean. **Czech is not on that list**, and Czech is exactly the language
|
||
that needs it: 1 kupón, 2–4 kupóny, 5+ kupónů — three forms where English
|
||
has two.
|
||
|
||
**What it found in our code.** A real bug, not a nicety:
|
||
|
||
```swift
|
||
Text("\(activeCoupons.count) coupons") // CardDetailView, checkout screen
|
||
```
|
||
|
||
The Czech translation in the catalogue is a single string, `%lld kupónů`,
|
||
so the app renders "1 kupónů" and "3 kupónů" — wrong for every count
|
||
except five and up. It sits on the checkout screen, which is the screen
|
||
people actually look at in a shop.
|
||
|
||
**The fix for us** is plural variations in the String Catalog rather than
|
||
`inflect:`. Xcode's "Vary by Plural" writes a `variations.plural` block
|
||
with `one` / `few` / `many` / `other`, and Czech uses all of them:
|
||
|
||
| category | counts | Czech |
|
||
|---|---|---|
|
||
| one | 1 | 1 kupón |
|
||
| few | 2–4 | 3 kupóny |
|
||
| many | fractions | 1,5 kupónu |
|
||
| other | 0, 5+ | 5 kupónů |
|
||
|
||
Worth auditing every interpolated count in the UI at the same time; a
|
||
count wrapped in parentheses — "Send unsent receipts (3)" — dodges the
|
||
problem entirely and is a reasonable fallback where a plural form would
|
||
be awkward.
|
||
|
||
## Testing a translation needs the right bundle, not the right locale
|
||
|
||
`String(localized:locale:)` looks like it renders a given language. It does
|
||
not: `locale:` drives number and date formatting, while the language comes
|
||
from the **bundle**. A test that passes `locale: Locale(identifier: "cs")`
|
||
and nothing else reads the English strings and reports them as if they
|
||
were Czech.
|
||
|
||
```swift
|
||
let path = Bundle.main.path(forResource: "cs", ofType: "lproj")!
|
||
let czech = Bundle(path: path)!
|
||
String(localized: "\(1) coupons", bundle: czech, locale: Locale(identifier: "cs"))
|
||
```
|
||
|
||
Our first attempt at the plural test got this wrong and failed with
|
||
`"1 coupon" == "1 kupón"` — which at a glance reads as "the plurals are
|
||
broken" when in fact the English plurals were working and the test was
|
||
looking in the wrong place.
|
||
|
||
Also: a plural variation must reference the number. A key extracted as
|
||
`%@ coupons` cannot carry one, and the build fails outright with "Plural
|
||
variation requires referencing the number in the string" — usable
|
||
feedback, and a reason to keep counts typed as `Int` so the key comes out
|
||
as `%lld`.
|
||
|
||
**Step forward?** Yes, and overdue. This is the kind of defect that never
|
||
shows up in a test and that a Czech speaker notices immediately.
|
||
|
||
**Source.** Natalia Panferova, "Handle plurals in SwiftUI Text views with
|
||
inflection", nilcoalescing.com, 13 Jan 2025. Read 18 Aug 2026. The article
|
||
is about `inflect:`; the Czech conclusion is ours, from checking which
|
||
languages that API actually covers.
|