·
iOS localization is a wild ride where device and app locales play by their own rules. But don’t worry, after some chaos, Apple’s settings actually matched our expectations. Of course, only after a few twists and turns
In the past few weeks, I’ve been working with my colleague Antonino Gitto, a senior software engineer with over five years of experience in mobile app development, and Marco De Lucchi (you might remember him from our previous post about iOS widgets) on improving how the lastminute.com mobile apps handle localization. And we’re doing it for a very special reason (that we cannot share today): something exciting is coming in the next few months, so stay tuned! 😏
At lastminute.com, many configurations depend on the user’s locale. A clear example is currency, which is explicitly
determined by these parameters. Until now, we’ve been using
react-native-localization to manage localization in our app.
However, the method provided by this library to retrieve the locale reads the AppleLanguages
user default.
We conducted several experiments to understand the logic behind the values stored in this user default, only to be astonished by the results: none of the returned values was matching our expectation in terms of locale with respect to the ones we defined in our apps. That’s when we realized we needed to gain a deeper understanding of how iOS determines a user’s locale.
This marked the beginning of our journey down the rabbit hole of iOS localization world. Along the way, we learned a lot about how iOS selects a locale for apps with multiple locales defined in its bundle.
Join us on this wild ride and avoid falling into the same traps we did when dealing with iOS locale madness!
iOS operates with two different levels of locale: device locale and app locale.
The device locale is set in Settings → General → Language & Region. It can either be selected directly from the Preferred Languages menu or derived from a combination of Preferred Languages and Region.
Here’s how it works in detail, and how iOS chooses the device locale based on these two settings:
English (UK)
sets the locale to en-GB
.English
(generic) and Italy
as the region results in a locale of en-IT
. Even if this is not
a locale that exists, it still makes sense as pointed by an Apple engineer in this post.
This can happen because you can have your native language set as the preferred one, but have the region set to
something else (eg. because for example you live abroad). Anyway, it is strange to see this "non sense" locale
created by iOS by combining the two fields above 😅.The app locale is the locale the app uses to select translations. This is determined through a specific algorithm (which we’ll call the app locale algorithm), as explained in this Apple documentation article (which, by the way, wasn’t easy to find! 😅).
Let’s take a look at a key excerpt from the article:
To determine the language for your app, iOS considers not only the user’s language preferences (set in General → Language & Region), but also the localizations your app supports. The process works as follows:
- iOS first checks the user’s most preferred language (the first entry in the Preferred Languages list).
- It then verifies whether this language is supported by the app by checking for a corresponding
.lproj
folder in the app bundle. If the folder exists, iOS selects that language. Otherwise, it moves on to the next language in the user’s preferences and repeats the process.- If the user’s preferred language is a regional variant that is not supported by the app, iOS attempts to fall back to a more generic language before giving up.
- Example: If the user’s preferred language is British English but the app does not include
en-GB.lproj
oren_GB.lproj
, iOS checks for anen.lproj
folder and defaults to English if available.- If none of the user’s preferred languages are supported, iOS defaults to the app’s development region (
CFBundleDevelopmentRegion
).
In summary, the app locale algorithm attempts to match the most specific locale supported by your app (as defined in the
Xcode project settings) with the user's language and dialect preferences in the Preferred Languages menu. It starts with
the most specific option and progressively falls back to less specific ones. If no match is found, the locale defined in
the CFBundleDevelopmentRegion
option in the Info.plist
is used as a fallback.
NB.: in the most recent version of Xcode (16 at the time of this writing), the .lproj
folder for the localisable string
has been partially replaced by the string catalogs for translations (.xcstrings
file), but the algorithm described
above is still valid.
Now that we understand how iOS selects the locale, how do we retrieve this information in code? There are a few key APIs in the iOS SDK:
Locale.preferredLanguages
: returns the list of available device locales. The first entry represents the device’s selected locale (i.e., the system-wide locale).
"AppleLanguages"
entry in UserDefaults
stores the same data and is used by react-native-localization to retrieve the locale.Bundle.main.preferredLocalizations
: returns locales included in the app bundle (as defined in the Xcode project).
The user can also manually override the app’s locale using the app-specific language settings. However, this menu is only available if multiple languages are selected in Preferred Languages. If you want the menu to always be available, you can force it by adding UIPrefersShowingLanguageSettings
to Info.plist
.
Clear as mud, right? 😆
To illustrate all of this, let’s explore some real-world examples using a small one-screen testing app we built called Which Locale?.
"Which Locale?" is a simple, single-screen app designed to help us compare the different locale settings discussed earlier and observe how they behave when the user changes either the device or app locale. Let’s walk through the Xcode project setup and the implementation of this app.
The app supports 5 different locales:
By default, the app’s locale is set to "English (United Kingdom)".
In the Info.plist
of the project we also added some settings:
CFBundleDevelopmentRegion
or usually called "Default localization" to be the $(DEVELOPMENT_REGION)
, that is
basically the "English (United Kindom)" default choosen in the previous screen.UIPrefersShowingLanguageSettings
to true, that as we will see later will enable a cool feture related to the app
language menuAs we mentioned before, the app consists of a single view, structured into sections that display the device and app locale settings using relevant iOS APIs.
The device locale section retrieves and displays:
Locale.preferredLanguages
"AppleLanguages"
entry in UserDefaults
The app locale section displays:
Bundle.preferredLocalizations
, that contains the locale selected by the app based on the device settingsBundle.main.localizations
, that contains the locales configured in the screen shown before where it is possible
to add locales supported by the app.import SwiftUI
struct ContentView: View {
var body: some View {
NavigationView {
List {
Section(header: Text("Device User Locale").font(.title2).bold()) {
LocaleSectionView(title: "Locale.preferredLanguages", languages: Locale.preferredLanguages, showCurrent: true)
LocaleSectionView(title: "UserDefaults AppleLanguages", languages: getAppleLanguagesArray(), showCurrent: true)
}
Section(header: Text("App Locale").font(.title2).bold()) {
LocaleSectionView(title: "Bundle.preferredLocalizations", languages: Bundle.main.preferredLocalizations, showCurrent: true)
LocaleSectionView(title: "Bundle.localizations", languages: Bundle.main.localizations, showCurrent: false)
}
Section {
Text("mobile_app.greetings")
.font(.headline)
.foregroundColor(.blue)
}
}
.navigationTitle("Which Locale?")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: openAppSettings) {
Label("Settings", systemImage: "gear")
}
}
}
}
}
private func openAppSettings() {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
private func getAppleLanguagesArray() -> [String] {
if let appleLanguages = UserDefaults.standard.array(forKey: "AppleLanguages") as? [String] {
return appleLanguages
}
return ["Not Found"]
}
}
struct LocaleSectionView: View {
let title: String
let languages: [String]
let showCurrent: Bool
var body: some View {
VStack(alignment: .leading, spacing: 5) {
Text(title).bold()
if showCurrent, let primary = languages.first {
Text("Current: \(primary)")
.font(.headline)
.foregroundColor(.blue)
}
ForEach(languages, id: \.self) { language in
Text(language)
.font(.system(.body, design: .monospaced))
.foregroundColor(.secondary)
}
}
}
}
By running the app and modifying the device language settings or overriding the app-specific language settings, we can observe how iOS dynamically adjusts both the device locale and the app locale based on user preferences.
Let's start with a simple case: language selected "English (United Kindom)" and region "United Kindom". In this case the
Locale.preferredLanguages
(which always matches AppleLanguages
) returns en-GB
and
also Bundle.preferredLocalization
returns en-GB
. This behavior makes sense because "English (United Kingdom)" is a
locale supported by the app, making it an easy match between the device's locale and the app's available localizations.
Now, let's add some complexity. This time, we will select "English (United Kindom)" as language but set the regionas
"Italy". What happens in this case? The locale at both device and app level is still "English (United
Kindom)". Why? Because the language selected is already a specific dialect, that represents itself a locale
(because it is a combination of language and region). So the "region" settings in this case is completely ignored, and
Bundle.preferredLocalization
will return again en-GB
.
The next case is another interesting one. The language selected in this case is "English", without specifying a
particular dialect, and the region selected it "Ireland". In this case, the locale is calculated as a combination of
language and region. As a result, both Locale.preferredLanguages
and Bundle.preferredLocalization
returns en-IE
.
Now, let's make things even more complex. What happens if we select "English" (without a specific dialect) and choose a region
that is not associated to that language in our app, like, for example, "Italy". In this case we encounter the first mismatch between
Locale.preferredLanguages
and Bundle.preferredLocalization
. The first one will return en-IT
, so a very basic
combination of the preferred language and region device settings. This allows iOS to:
Even though this behavior might seem counterintuitive at first, it actually makes sense. A user with this configuration might be a native English speaker living in Italy, so iOS attempts to mix and match the language and region settings accordingly.
On the other hand, Bundle.preferredLocalization
returns en-GB
. Why? This follows the algorithm we discussed earlier:
en-IT
, but the app only supports en-GB
and en-IE
.CFBundleDevelopmentRegion
setting, which defaults to en-GB
.Let's explore a more complex case, where the device locale configuration includes:
fr
it
FR
This is very tricky (and a common case for users like me that have multiple preferred languages selected in the
settings). In this case iOS will return fr-FR
as the locale from Locale.preferredLanguages
. This makes sense
because at system level, the locale considers only the first (main) entry in the language settings when determining the device locale.
However fr-FR
is not supported by the app. But, since the user has it
as second preferred language,
Bundle.preferredLocalization
will return it
as locale. This happens because the second language matches a generic
locale (i.e., it
) supported by the app, even though the region is not directly supported.
If we remove the it
language as the second option from the preferred languages, the situation changes. Since there is no
longer a supported language in the preferred languages list, the Bundle.preferredLocalization
will not find any match.
In this case, it will simply fallback to the CFBundleDevelopmentRegion
(en-GB
).
This happens because, without a matching supported language, iOS will default to the CFBundleDevelopmentRegion
specified in the app's configuration, ensuring that the app can still present content in a default locale when no other
match exists.
Last but not least, what does happen if the preferred language in the system settings is not supported by the app
but the region is? For example we have fr
as preferred language and ch
as region. In this case, even if we have a
locale that supports that region (it-CH
) in the app, the system will first try to match based on the language. In fact
if there is no match for that language the system fallbacks to CFBundleDevelopmentRegion
.
So the region is only used to resolve conflicts if there is a first match between the language across multiple locales.
If there is no matching language, the region setting is ignored, and iOS will fall back to CFBundleDevelopmentRegion
.
That's it. As you can see, the cases are quite interesting, and once you start to fully understand the algorithm at the OS level, everything makes sense.
There's one last aspect to cover before wrapping up, related to how iOS manages app menu settings. Up until now, we've focused on the device settings, but what if you want the user to customize the app's language via the app’s settings section? You might expect iOS to make this easy, but... it doesn't. In fact, if the user has only one language selected in the preferred languages option in the device settings, the language menu in the app will not appear 😨. This can lead to frustrating situations where users are redirected to the settings section of the app, only to find there's nothing they can change 😅.
We were quite desperate about this issue, but then, buried in a WWDC 2024 video,
we found a solution. Do you remember when we mentioned we added UIPrefersShowingLanguageSettings
to the Info.plist
? With
this option, the language menu will always be visible in your app's settings. Additionally, if a user selects a locale
from the ones supported by your app, that locale will be added to the device’s preferred languages, either as a generic
language or a dialect, depending on how you've defined the locales in your app. Check out the video below to see a live
example of how this option works.
The codebase for the "Which Locale?" app can be found in this github repo. Feel free to experiment with it and run your own tests. Everything should be consistent with what we've covered in this post 🚀. We'll do our best to keep this post updated if anything changes on Apple’s side. See you next time (we hope not again for the "locale madness" topic 😆).