iOS apps that ship in one country usually don’t think much about language: take the device language, run the localization system, done. Apps that ship in several don’t have that luxury.
A hybrid app I worked on shipped across multiple European markets. Each market supported a different subset of languages. Users could also pick a language explicitly and have it stick. Switching markets had to validate that the chosen language was still supported there, falling back gracefully if not.
iOS’s default locale handling doesn’t support this. We built a small layer on top, with the architecture taking inspiration from the open-source COVID-19 iOS app by the UK Health Security Agency.
The shape of the layer
Three responsibilities feed into one place. Inputs are anything that can change the resolved language: the user picking one explicitly, the market switching, or a remote config update changing the list of supported languages. The language layer holds the persisted preference, knows the per-market rules, and computes the resolved language from both. Subscribers (the tab bar, the embedded WebView, the network client) get the resolved language as an observable value and reconfigure themselves when it changes.
Preference as a value
The user’s choice became a value type with two cases: follow the device, or use a specific language.
enum LanguagePreference: Codable {
case systemPreferred
case custom(languageCode: String)
}
The distinction matters. A user on a German device who has explicitly chosen German is in a different state from a user whose German is implicit from the device. If the first switches their phone to French, the app stays in German. If the second does, the app follows.
The value lived in an observable property. When it changed, the rest of the app reacted: the tab bar reloaded, the WebView’s content language switched, network requests carried the new locale.
Validation against context
A language preference is only meaningful inside a market. Switching markets triggered a resolver that walked a small fallback chain:
func resolve(preference: LanguagePreference,
for market: Market,
supportedLanguages: [String]) -> String {
// 1. User's explicit choice, if still supported here.
if case let .custom(code) = preference, supportedLanguages.contains(code) {
return code
}
// 2. Device language, if supported here.
let device = Locale.preferredLanguages.first ?? "en"
if supportedLanguages.contains(device) {
return device
}
// 3. Market's default.
return supportedLanguages.first ?? "en"
}
The list of supported languages per market came from remote config, not baked-in code.