Mobile App Localization: i18n, RTL, Dynamic Language Switching
Localization is not just translating strings. The date "04/05/2024" in the US means April 5, in Europe—May 4. The sum "1,000.50" in most countries is one thousand and a half, in Germany—one and fifty cents. The number of words for "1 file," "3 files," "5 files" in Russian is three different forms; Arabic has six plural forms. If app architecture doesn't account for this from the start, localization becomes a series of patches.
Basic Infrastructure: Strings, Formats, Pluralization
iOS: Localizable.strings and String Catalogs
Historically, iOS strings were stored in Localizable.strings—a simple key=value format. With Xcode 15, String Catalogs (.xcstrings) appeared—a JSON-based format that stores all locales in one file, displays translation status (translated/outdated/missing) and is integrated with Xcode UI.
String(localized: "welcome_title") in Swift 5.7+ instead of NSLocalizedString("welcome_title", comment: ""). Shorter, more type-safe. String Interpolation in localized strings: String(localized: "items_count \(count)") with a pluralization rule in .xcstrings—the system automatically selects the right form for the language.
Pluralization via .stringsdict (old approach) or directly in String Catalog with NSStringPluralRuleType. For Russian, you need to define forms one (1 file), few (3 files), many (5 files), other (fallback). Skip few for Russian—you get "5 files" where it should be "3 files."
Android: XML Resources and String Formats
res/values/strings.xml for the base locale (en), res/values-ru/strings.xml for Russian. Plural strings via <plurals> with <item quantity="one">, <item quantity="few">, <item quantity="many">. resources.getQuantityString(R.plurals.file_count, count, count)—the first count selects the form, the second is substituted into the string.
In Compose: stringResource(R.string.key) and pluralStringResource(R.plurals.file_count, count, count). Type-safe alternative—Lyricist library, which generates typed strings from annotations.
Android App Bundle with android:splitByLocale="true" in bundle.gradle—resources are delivered only for device languages. APK shrinks, needed locale resources are loaded via Play Asset Delivery. Important: on Android 8+ Configuration.locales is a list, not a single language.
Flutter: intl and Abstraction Layers
Flutter intl package—standard. AppLocalizations.of(context).welcomeTitle is generated from ARB files (app_en.arb, app_ru.arb). flutter gen-l10n generates typed code. Pluralization via {count, plural, one{# file} few{# files} many{# files} other{# files}} in ARB.
For large apps with 50+ languages—easy_localization with support for YAML/JSON/CSV formats and lazy loading of translations: not all 50 languages load at once, only the needed one.
RTL: Right-to-Left Writing
Arabic, Hebrew, Persian, Urdu—RTL (Right-to-Left) languages. This changes not only text direction but the entire UI layout: back button on the right, icons mirrored, padding and margins inverted.
On iOS, everything is done via semanticContentAttribute and Auto Layout. Layout constraints with leading/trailing (not left/right) automatically invert on RTL. UIView.semanticContentAttribute = .forceRightToLeft for a specific component. System components (UINavigationController, UITableView, UIStackView) switch automatically on RTL locale. Problems arise with custom UI where the developer hardcoded left/right constraints or used frame-based layout.
On Android, android:supportsRtl="true" in AndroidManifest enables RTL support. start/end instead of left/right in XML attributes: paddingStart, layout_marginEnd, textAlignment="viewStart". LayoutInflater with android:layoutDirection="rtl" for preview. Icons with direction (arrows, chevrons) must be mirrored—android:autoMirrored="true" in drawable for automatic inversion on RTL.
On Flutter, Directionality widget with TextDirection.rtl manages direction for the subtree. Padding(EdgeInsetsDirectional.fromSTEB(...)) instead of EdgeInsets.only(left:...). Row automatically accounts for TextDirection from Directionality. Most Material widgets are RTL-ready, but custom CustomPainter—no: you need to get TextDirection from context and account for it manually.
RTL testing: on iOS Settings → General → Language & Region → Region: Saudi Arabia switches to RTL mode without changing system language. On Android adb shell setprop debug.force.rtl 1 forces RTL for debugging.
Dynamic Language Switching
Switching language without restarting the app is a non-trivial task, especially if the system is built on system locale.
iOS doesn't natively support app language change without restart. The cleanest approach—store the selected language in UserDefaults, on startup create a Bundle with the needed localization, use a custom NSLocalizedString through this Bundle. Bundle.setLanguage("ru") via swizzling Bundle.localizedString(forKey:value:table:)—works, but this is runtime swizzling, which is not ideal. Alternative: your own string system on top of NSBundle, which rereads files on language change. On switch—recreate the root ViewController.
Android API 33: LocaleManager.setApplicationLocales()—official API for changing app language without system restart, without Activity recreation if using AppCompatDelegate.setApplicationLocales(). Before API 33—Configuration.setLocale() + recreate() for Activity. On language change, notify all open Activities via broadcast or ViewModel.
Flutter—simplest of the three. LocalizationsDelegate reloads when locale changes in MaterialApp. Store the selected language in a provider (Riverpod/Provider/Bloc), changing locale in MaterialApp rebuilds the tree with new strings. Practically no boilerplate when using easy_localization.
Formatting Dates, Numbers, Currencies
DateFormatter (iOS) and DateFormat (Android, intl)—always with an explicit locale, never without it. DateFormatter().dateStyle = .medium with locale = Locale(identifier: "ru_RU") gives "May 4, 2024" (in Russian format), with Locale(identifier: "en_US")—"May 4, 2024".
NumberFormatter / NumberFormat.currency() for currencies. Currency symbol, thousand and decimal separators—all locale-specific. Hardcoding "₽" or "." as a separator is an error. Locale(identifier: "ru_RU") + NumberFormatter.numberStyle = .currency with currencyCode = "RUB" gives correct formatting automatically.
Relative time ("2 hours ago," "yesterday"): RelativeDateTimeFormatter (iOS 13+) and RelativeTimeFormatter via intl package on Flutter/Android—don't reinvent the wheel with manual formatting.
Common Localization Mistakes
String concatenation instead of formatting: "Hello, " + name + "!" works for SVO-languages, but in Japanese, the name comes before the greeting. String(format: "greeting %@", name) with greeting = "%@ さん、こんにちは" in the Japanese file—correct.
Fixed UI size for text. German is on average 30% longer than English. AutoLayout with proper constraints, adjustsFontSizeToFitWidth where allowed, dynamic row height changes via UITableView.automaticDimension.
Images with embedded text—require localized versions or replacement with text overlay.
Timeframe
Adding one new language to an already localized app (strings only, no RTL)—2–3 days of technical work + translation time. Initial localization infrastructure setup from scratch, including RTL support and dynamic language switching—2–4 weeks.







