How to use adaptive width strings for localization on iOS

The Apple App Store is a huge market with potential users and customers from all over the world. With this global reach, it’s more important than ever that your app is ready to be used by people that don’t speak English. For example, at PSPDFKit, a PDF framework that is used by a lot of apps in the App Store, we localize our framework in 29 languages, so that users don’t see parts of an app in English and parts in their local language, which would be a bad experience.

One of the challenges of localization lies in the length of translated texts. Languages like German are especially problematic because of its longer texts compared to English. In addition to that, translators are often working with isolated strings, where the only context they get (if they get any) is the place where the string is going to be placed and its purpose, but they don’t usually have any idea about the available physical space on the screen. Moreover, the available space may not be constant as the same app may be run on an iPhone or an iPad (or even a Mac in the future?). This generally leads to a not so ideal user experience: either the text is cut with ellipsis on some devices, or the same text has different sizes depending on the language, or we decide to arbitrarily shorten the localized text for a particular language, creating a lot of wasted space for iPad Pro users, and penalizing them with less explanatory strings.

In order to solve this problem, if you don’t want to engineer your own solution Apple introduced “adaptive strings” with iOS 9. This feature is based on string dictionaries (.stringsdict files), which are commonly used to support pluralization rules in apps. If your app does not already have a .stringsdict file, you can create one using Xcode: Go to File, New, File (or press Command+N) and select “Stringsdict file” from the template list.

Screen Shot 2018-02-10 at 2.15.01 PM

Xcode dialog box to create a new Stringsdict file.

Once the Stringsdict file is created, you have to modify its contents so that it supports adaptive strings. For each key that you want to support multiple localizations, add a NSStringVariableWidthRuleType dictionary with key/value pairs, one for each “class” of screen width that you want to support. The key must be a number that represents in an abstract way the screen width (more on this later), and the value is the localized string. Here’s a sample .stringsdict file showing different possible welcome messages in Spanish:

Screen Shot 2018-02-10 at 9.22.49 PM

You can download a sample Xcode project from here. If you compile and run the app on an iPhone 5s, 6, and iPad Pro you’ll get three different experiences: You’ll read the shortest localized string (“Hi”/”Hola”) on an iPhone 5s, “Welcome”/”Bienvenido” on an iPhone 6, and “Welcome to my app”/”Bienvenido a mi app” on an iPad Pro.

What do the 20, 25, 50 numbers mean?

They look like “magic” numbers, but they do actually have a meaning: they intend to abstract the width that is available to show text. Let’s see how UILabel presents localized text on the screen when variable width strings are in place:

When you call NSLocalizedString("STRING_KEY", "String context"), this macro returns an NSString instance that you can set to a UILabel via its text property. Very simple API. However, remember that NSString is actually a class cluster, that is, a group of classes that are exposed via a single public abstract superclass. This design pattern was used in NSLocalizedString when the support for plurals was added. With the introduction of adaptive strings, the same design pattern was used, and what the function actually returns is an internal subclass of NSString, __NSVariableWidthString. When you set the text of a UILabel, the system internally queries if the NSString that you are passing is an instance of __NSVariableWidthString and, in that case, extracts an appropriate text variant. There’s a public category on NSString inside NSBundle to do this: variantFittingPresentationWidth(_:) Its public documentation is empty, but the header file explains a little bit more:


/*
For strings with length variations, such as from a stringsdict file, this method returns the variant at the given width.
If there is no variant at the given width, the one for the next smaller width is returned. And if there are none smaller,
the smallest available is returned. For strings without variations, this method returns self.
The unit that width is expressed in is decided by the application or framework. But it is intended to be some measurement
indicative of the context a string would fit best to avoid truncation and wasted space.
*/
– (NSString *)variantFittingPresentationWidth:(NSInteger)width API_AVAILABLE(macos(10.11), ios(9.0), watchos(2.0), tvos(9.0));

If you feel that the public documentation of this method should at least contain the comments of the header file where it is declared, please duplicate this Radar. UILabel calls this API when you set the text, but how does it generate a value for the width parameter? The header documentation explains that this heuristic is basically decided by the client. UILabel first extracts the width of the UIWindow where it is placed. Then, it creates an NSAttributedString instance with an uppercase “M” and a default font for body text. Dynamic type, introduced on iOS 7, helps with this by providing convenient API: let font = UIFont.preferredFont(forTextStyle: .body) The width parameter is calculated as the number of “M”s that can fit inside the UIWindow’s bounds width, that is, UIWindow.bounds.width / emWidth

The important thing that you need to remember about this heuristic is that it is directly proportional to the screen width and that the exact numbers you put in the .stringsdict file are not as important as the relationship between them. For example, you could also create two keys: 1, for small devices, and a bigger number like 30 to have a more detailed translation on devices with more available space. This is the approach that Apple follows for most of iOS stock apps.

Conclusion

To sum up, Apple provides support for adaptive localized strings via .stringsdict files in two ways:

  • If your codebase uses UIKit components, you don’t need to do anything, the system will show appropriate adaptive strings if you add a .stringsdict file in the way described in this article.
  • If you don’t use UIKit components or you need more flexibility, simply use the variantFittingPresentationWidth(_:) instance method of NSString to manually get the text from the desired key in the .stringsdict file. Something like this:


let string = NSLocalizedString("WELCOME_MESSAGE", comment: "This is the welcome message.") as NSString
let adaptedString = string.variantFittingPresentationWidth(25)

Note that variantFittingPresentationWidth(_:) also returns an instance of the internal type __NSVariableWidthString, so if you set the result of this API to the text property of UILabel, it will always be overridden by UIKit’s own heuristic. This has confused people in both Apple’s developer forums and StackOverflow. One possible solution to this is to use string interpolation:

welcomeLabel.text = "\(string.variantFittingPresentationWidth(25))"

And that’s it, I hope you enjoyed this article and maybe inspired you to build something on top of this API to improve your localization workflow.

This entry was posted in iOS, Xcode and tagged , , , . Bookmark the permalink.

3 Responses to How to use adaptive width strings for localization on iOS

  1. Eric says:

    Thanks for the writeup and prodding Apple. I’m curious if you’ve seen any issues with UIKIt selection the appropriate representation for a label with dynamic type. I’ve tried turning up the dynamic type size all the way to the max value and I’m not seeing UIKit step down to the next smaller string when I cross the sizing boundary. I’m trying to figure out if I’m doing something wrong or if there’s an issue in UIKit.

    Like

    • mardani29 says:

      I haven’t seen any issue like that. Do you have a small project that reproduces the problem?

      Like

      • Eric says:

        I forked your demo and did what I thought was necessary to adopt dynamic type support for variable width strings.

        https://github.com/jablair/AdaptiveStringsExample

        Launching the SE sim in landscape mode seems to sit near the boundary of the 20 and 25 width string, so that seems like a good test case. Weirdly, you get the 25 string if you launch in landscape and the 20 string if you rotate to landscape. That radar’s been filed.

        Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s