Embracing Modern Localization with String Catalogs

Localization is key to making apps feel native for users worldwide, but handling dynamic strings—like dates, counts, or region names—can be tricky. With Xcode’s String Catalogs (.xcstrings), introduced in Xcode 14, Apple provides a modern, streamlined way to manage translations and format strings for multiple languages. For more background, check Apple’s official documentation.

Advertisement

As we prepared our recent update for Year Progress, a clean and elegant time-tracking app, we wanted to elevate our localization game. With users across Japan, Taiwan, and mainland China, we needed seamless support for Japanese, Simplified Chinese, and Traditional Chinese. This wasn’t just about translating static labels—it meant localizing dynamic strings, such as:

  • Q2 2025 Progress
  • Week 15, 2025 Progress
  • %d regions selected

These phrases combine variables (numbers, regions, dates) with localized structure and grammar. This post shares our journey modernizing our localization flow using String Catalogs and Swift’s String(localized:) API.


The Problem: Naive String Concatenation

Our earlier approach relied on code like:

"\(region.rawValue) Holidays"
"\(selectedRegions.count) regions selected"
"\(formatter.string(from: date)) Progress"

This worked in English but fell apart in other languages. For example, in Japanese, August 2025 Progress should read 2025年8月の進捗, with the year and month flipped and a possessive particle (の). In Chinese, word order and lack of plural forms add further complexity. String concatenation couldn’t handle these differences, leading to awkward or incorrect translations.

Solution: Structured Localization Using String Catalogs

We adopted String Catalogs, Apple’s recommended tool in Xcode 15+ for managing localization. Each dynamic string became a localization key with a format string. For example:

For region selection:

"region_selected_singular" = "%d region selected"
"region_selected_plural" = "%d regions selected"

We created a Swift extension to handle plurality:

extension String {
    func localizedPlural(_ count: Int) -> String {
        let key = count == 1 ? "\(self)_singular" : "\(self)_plural"
        let format = String(localized: String.LocalizationValue(key))
        return String(format: format, count)
    }
}

Usage was straightforward:

Text("region_selected".localizedPlural(selectedRegions.count))

Advanced Option: Using .stringsdict for True Pluralization

For languages with complex plural rules (like Arabic or Russian), we explored .stringsdict for more robust localization. Here’s an example:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>region_selected</key>
  <dict>
    <key>NSStringLocalizedFormatKey</key>
    <string>%#@regions@</string>
    <key>regions</key>
    <dict>
      <key>NSStringFormatSpecTypeKey</key>
      <string>NSStringPluralRuleType</string>
      <key>NSStringFormatValueTypeKey</key>
      <string>d</string>
      <key>one</key>
      <string>%d region selected</string>
      <key>other</key>
      <string>%d regions selected</string>
    </dict>
  </dict>
</dict>
</plist>

Usage simplified to:

Text(String.localizedStringWithFormat(NSLocalizedString("region_selected", comment: ""), selectedRegions.count))

Which approach to choose? The String extension is lightweight and great for simple apps with few languages. However, .stringsdict shines in larger projects, as it supports complex plural rules natively and scales better for languages with multiple forms (e.g., “one,” “few,” “many”).

Handling Date and Time Structures

Instead of concatenating strings like “(formatter.string(from: date)) Progress”, we used structured formats in the String Catalog:

"month_progress_format" = "%@ Progress"
"week_progress_format" = "Week %d, %d Progress"

Then applied them:

let label = String(format: String(localized: "month_progress_format"), monthString)

This ensured natural translations. For example, Japanese uses 2025年8月の進捗 (year-month structure with a possessive particle), while Chinese prefers 2025年8月进度 (no particle, simpler grammar). These differences highlight why structured formats are essential.

Here’s a quick comparison of translations:

Context English Japanese Simplified Chinese
Month Progress August 2025 Progress 2025年8月の進捗 2025年8月进度
Week Progress Week 15, 2025 Progress 2025年第15週の進捗 2025年第15周进度
Region Selection 2 regions selected 2つの地域が選択されました 选择了2个地区

Region Names as Keys

To localize region names like “France” or “Japan”, we added them as keys:

"France" = "フランス"
"Japan" = "日本"

And retrieved them with:

let regionName = String(localized: String.LocalizationValue(region.rawValue))

This enabled localized strings like:

let format = String(localized: "region_holidays_format")
let title = String(format: format, regionName)

Reordering Parameters in Localized Strings

When localizing dynamic content, especially strings that include multiple variables like “Q2 2025 Progress”, different languages may require reordering the arguments to match their natural grammar.

Take this example:

  • English: Q2 2025 Progress
  • Chinese: 2025年第2季度进度

Here, the year appears before the quarter in Chinese, unlike in English.

To support this, we used positional specifiers in our String Catalog entries.

Example: quarter_progress_format

English (default):

"quarter_progress_format" = "Q%1$d %2$d Progress";

Simplified Chinese:

"quarter_progress_format" = "%2$d年第%1$d季度进度";

Japanese (if needed):

"quarter_progress_format" = "%2$d年第%1$d四半期の進捗";

In Swift, you can format the string like this:

let label = String(format: String(localized: "quarter_progress_format"), quarter, year)

Even though the variable order in code is quarter, year, the correct positions are used in each language thanks to %1$ and %2$.

This technique ensures that localized strings follow the target language’s natural sentence structure, without needing to restructure logic in code.

Why Positional Specifiers Matter

Without positional control, you’d need separate format logic for each locale, leading to brittle, duplicated code. By placing structure in the localization file instead of the source code, you achieve:

  • Better separation of logic and language
  • Cleaner code
  • Easier translation workflows
  • Correct grammar and word order for all locales

Final Result: Fully Localized Composition

In Year Progress, we track time across dimensions like years, quarters, months, or weeks. We defined a dimension enum to represent these, and our updated label generator reflects this:

switch dimension {
case .year:
    let format = String(localized: "year_progress_format")
    return String(format: format, year)
case .quarter:
    let format = String(localized: "quarter_progress_format")
    return String(format: format, quarter, year)
case .month:
    let format = String(localized: "month_progress_format")
    return String(format: format, monthString)
case .week:
    let format = String(localized: "week_progress_format")
    return String(format: format, weekNumber, year)
}

This approach ensures every label respects the target language’s grammar and structure.

Takeaways

Using String Catalogs for dynamic content localization:

  • Prevents word order issues across languages
  • Handles pluralization and number agreement seamlessly
  • Delivers a natural, native experience for users
  • Streamlines developer workflows with String(localized:)
  • Enhances user trust by making the app feel “local”

Switching to String Catalogs transformed Year Progress into a truly global app. If you’re still stitching strings together with concatenation, stop now. Your international users—and your future self—will thank you.