iOS Bad news: Bundle.preferredLocalizations() is not 100% reliable
Imagine this problem: Given a theoretical list of supported languages of an app, retrieve the best match of that list, according to the device’s preferred languages (as defined in the Settings).
Spend some time reading the problem statement. I’m going to break it down into pieces. Let’s start.
Background
When talking about language lists, there are two lists we could be referring to:
- The languages supported by the app (obtained via
Bundle.main.localizations
). You can see this list in Xcode by navigating to Project Settings -> Your project -> Info Tab -> Localizations. (Note: exclude “Base”. I’ll talk more about Base in a future post, but for now, just imagine it isn’t there).
2. The device’s list of preferred languages (obtained via Locale.preferredLanguages
). Needless to say, this list is ordered by preference. You can see it by navigating to Settings -> General -> Language & Region
Both of these lists are used by iOS to determine the language that should be used to display an app. The process is described in detail here. In a nutshell, this is how iOS determines the best match:
- Select the top-most language of the device’s list of preferred languages, such that it also appears in the list of app-supported languages (see nuance below*).
- If none of the languages met this condition, then fallback to the language set in your
Info.plist
's development region (CFBundleDevelopmentRegion
).
*Nuance: iOS doesn’t look for a strict match. It’s quite intelligent in identifying the best language. For example, if the user’s most preferred language is Swiss French, but the app only supports Canadian French, then it determines to use Canadian French.
WARNING: Please ensure that your CFBundleDevelopmentRegion
is the same as your app’s development language. If you don’t do that, you will be going through a very unhappy (and possibly buggy) path.
To summarize, for this algorithm, we need 3 requirements:
- Requirement #1: The device’s list of languages, ordered by preference
- Requirement #2: The app’s list of supported languages
- Requirement #3: A language to fallback to (which obviously is part of the app’s list of languages).
Solving the problem
If you read the problem statement again, you will see that from the 3 requirements laid above, we want to manually provide requirement #2. That is, we don’t want the algorithm to use the app’s list of languages — we want to provide ourselves a theoretical one as an input.
Your best bet to solve this problem is to use one of the Bundle.preferredLocalizations()
class functions. These functions intend to expose the iOS language-resolution algorithm that I described above.
There are two variants of these functions:
Bundle.preferredLocalizations(from:forPreferences)
allows you to provide requirement #1 (forPreferences
parameter) and requirement #2 (from
parameter).Bundle.preferredLocalizations(from:)
only allows you to provide requirement #2, and it uses the device’s preferred languages list (as defined in the Settings)
But…wait a minute. If we were to provide requirement #2, then we also need #3. Otherwise, which language the algorithm will fallback to?
Unfortunately, that’s one of the things thatBundle.preferredLocalizations()
fails to account for. Both of the variants have the defect that they don’t let you provide requirement #3. And that doesn’t make sense at all.
I will talk more about this defect later on. But right now, for the sake of completeness, I need to explain a very important nuance of these functions that you should be aware of. You can try reading the official documentation (here, here and here), but you might end up really confused (like this guy was), so I’m going to explain it myself.
A nuance you should be aware of
Given a list of languages let languages = ["en", "es", ...whatever]
, as counter-intuitive that this might sound, you should know that:
Bundle.preferredLocalizations(from: languages) != Bundle.preferredLocalizations(from: languages, forPreferences: nil)
The left-hand-side variant (the one that only accepts the from
parameter) is doing an additional first step that the right-hand-side variant isn’t: it’s filtering out the languages from the input argument that do not belong to the real app’s list of supported languages. In other words, it's doing this:
func preferredLocalizations(from input: [String]) -> [String] {
let cleanedInput = input.filter {
languagesSupportedByApp.contains($0) }
// ...
// algorithm continues using cleanedInput
// ...
}
Why is it doing this? I’m guessing it’s because that function was intended to be called when you try to load a resource that will be localized externally (like loading the correct translated version of a text file from a server). And that function wants to avoid selecting a language that isn’t even supported by the app.
On the other hand, the variant that accepts both the from
and forPreferences
parameters DOESN’T do this step. This is a very important difference that is not explained clearly enough in the documentation.
The defect
I previously said that neither of the preferredLocalizations()
functions allow you to specify a fallback language in case there isn’t any match at all.
You might be saying, “Well…maybe those functions return nil
when there’s not a match”. That is a very solid and sound design. Unfortunately, if you look at the signatures of the functions, you can see that their return value is [String]
— it’s not [String]?
.
You can test this behavior. Setup your phone so your preferred languages are Spanish and Italian, for example. Then create a simple Hello World app from scratch in Xcode, and do the following in the viewDidLoad
function of the ViewController.swift
override func viewDidLoad() {
super.viewDidLoad()
print(Bundle.preferredLocalizations(
from: ['de', 'fr', 'pr'],
preferences: nil
))
}
The code above will print [de]
.
It doesn’t make sense at all. We can tell that there was no match between the device’s list of preferred languages and the availableLanguages
input. But why is it printing de
? Who said that German was the fallback language?
No one said that. It’s just that the function wasn’t well designed, and it doesn’t let you specify a fallback language. It just picks randomly one of the languages of the input as the fallback.
What if we had used the variant Bundle.preferredLocalizations(from:)
in this example? Let’s try that:
override func viewDidLoad() {
super.viewDidLoad()
print(Bundle.preferredLocalizations(from: ['de', 'fr', 'pr'])
}
The above….also prints [de]
!
What!??! Didn’t we say that the first thing that the one-argument variant does is filter the input list so that it contains only app-supported languages?
Yes, we did say that. But this is another weird nuance: if the language resolution algorithm doesn’t find a match, this function goes back to the original input, picks one element randomly, and uses that as a fallback. Crazy, isn’t it?
[[[[Small side note: I also consider a defect the fact that both of these functions (as well as many others in the iOS API) use an array as their return value. The language resolution algorithm outputs a single language, no need to return an array. You might argue that these functions return not only the best match, but also the second best match and so on. But I’ve never seen them return more than one element.
Anyways, we can just simply get the first
property of the array and move on.]]]]
Workaround
Ok, so we’ve just confirmed that these functions have certain nuances and they have a defect. Can we work around it?
Before saying the workaround, I should say that it is not enforced by the API. Apple just specifies that, when there isn’t any match, it just returns a random element. What I’m about to say might be subject to change in the future, so you’re warned.
The workaround is: I’ve noticed that, in absence of a match, these functions always return the first element of the array as the fallback. So you could specify the fallback language in advance by putting it at the front of the list.