So far, TapALap has been only in English. For a large app with a large customer base, you’ll want to support multiple languages. Think of every new language you support as an entirely new market of users. The process of supporting multiple languages is localization, commonly abbreviated L10n. Internationalization, or i18n for short, is the similar process of adapting your app for different locales and cultures, going beyond the written word.
To localize TapALap, first you need to tell Xcode that you intend to support another language. Open the project settings by clicking the top-level TapALap in Xcode’s Project Navigator (⌘1). Select TapALap under Project, and then select the Info tab. Under Localizations, tap the + button to add a new localization. Select French, and Xcode will present a window with some options for localization. Keep them all selected and choose Finish. Great! Now you have French as a localization option, as shown in the figure. But how do you put in different words?
Look at Interface.storyboard in the Project Navigator, and notice a disclosure triangle to its left. Selecting it reveals the Spanish version of the Interface.strings file, which Xcode created to store the localized versions of all of the strings in the storyboard. Open the file to see a list of all of the strings in the storyboard. If you replace the contents with their French versions, your French users will automatically see the French text. Here are a couple of examples:
| /* Class = "WKInterfaceButton"; title = "Stop Run"; ObjectID = "0Fd-Oq-zqB"; */ |
| "0Fd-Oq-zqB.title" = "Arrêter la course"; |
| |
| /* Class = "WKInterfaceLabel"; text = "Run Date"; ObjectID = "0Ta-W8-PXh"; */ |
| "0Ta-W8-PXh.text" = "Date de la course"; |
| |
| /* Class = "WKInterfaceLabel"; text = "Lap"; ObjectID = "2OB-6V-MaP"; */ |
| "2OB-6V-MaP.text" = "Tour"; |
| |
| /* Class = "WKInterfaceButton"; title = "Finish Lap"; ObjectID = "3AS-d7-y0K"; */ |
| "3AS-d7-y0K.title" = "Finir le tour"; |
Getting Good Localized Content | |
---|---|
For this example text, I used a professional service to translate the app. You may be tempted to use an online tool such as Google Translate to perform these translations for you, but be careful! With it, at best your app will be an awkward, obviously machine-generated translation, but at worst it will be mistranslated in a way that generates offense or otherwise misses the mark for your brand. In a real, shipping app, you’ll want to use a translation service to obtain actual translations of your text. These services aren’t wildly expensive compared to the potential upside of reaching a new market of customers. |
Before you ship your localized app, you’ll want to see it running in another language, of course. Aside from running it on a device that’s set to the target language, there are two ways to preview your localized edits. The first way is the Preview feature in Interface Builder to see the app running in two languages at the same time. Open Interface.storyboard and open the Assistant Editor with ⌘⌥↩. Open the Preview interface, and create two 42mm watches. Select one of them by clicking it, and then in the lower-right corner, click English and change it to French. You can now see both languages next to one another, as in the image here.
As you can see, there’s already a problem: the text is too wide in a couple of places in French. This is where the preview earns its keep; as you tweak your UI to make things work, you see the results in all languages. (German is notorious for long words.) Once you’ve tweaked things to your liking, it’s time to run the app in the iOS Simulator. Simply change the language in your iOS Simulator’s Settings app to French, and you’ll be able to see the changes as you run the app.
While you can certainly run your app on a real Apple Watch and set its language and region settings in the Settings app, managing the language that way is a hassle. The last thing you want is to be stuck trying to find the right area of the Settings app to change it back because you can’t read anything! Fortunately, Xcode has a feature to help. An Xcode scheme can be set to run the app in a specific language or region. One good trick is to duplicate your main scheme and give it a language-specific name, then open the Edit Scheme window with ⌘<, select Run on the left, and then select the Options tab. Setting Application Language and Application Region, as you can see in the screenshot, will run the app with the given settings. Try it out!
Not every piece of text in your app lives in the storyboard. For text that you use in code, you’ll use another strings file. In Xcode, select File → New → File…, and select Strings File under Resource in the watchOS section on the left. Name the file Localizable.strings and make sure it’s added to the WatchKit extension. This file will have multiple versions, one per language, but it starts with just an English version. To add another version, open the file and head to the Identity Inspector (⌘⌥1). Under Localization, click Localize… and select Localize from the resulting dialog. Now, under the Identity Inspector, you can select the languages you’d like to localize.
The Localizable.strings file has the same format as the storyboard’s strings file, albeit without some of the extra storyboard data. Add a French version and add a few translations:
| "Error" = "Erreur"; |
| |
| "You need to select a track to run on!" = |
| "Vous devez sélectionner une piste sur laquelle courir !"; |
| |
| "OK" = "OK"; |
| |
| "None" = "Aucun"; |
Now, in order to use these strings in code, you need to read this file. Open GoRunningInterfaceController.swift to make the modifications. First, you modify the Selected Track button—if no track is selected, you use the text “None” for its name. Let’s localize that string using NSLocalizedString(_:comment:), which pulls the correct text out of the app bundle:
| func updateTrackLabels() { |
| if let track = selectedTrack { |
| trackNameLabel.setText(track.name) |
| |
| trackDistanceLabel.setText( |
| distanceFormatter.stringFromMeters(track.lapDistance)) |
| } |
| else { |
» | trackNameLabel.setText(NSLocalizedString("None", comment: "")) |
| trackDistanceLabel.setText(nil) |
| } |
| } |
Next up, you handle the error message that appears when you try to start a run with no track selected:
| @IBAction func startRunButtonPressed() { |
| guard let track = selectedTrack else { |
» | presentAlertControllerWithTitle(NSLocalizedString("Error", comment: ""), |
» | message: NSLocalizedString("You need to select a track to run on!", |
» | comment: ""), |
» | preferredStyle: .Alert, |
» | actions: [ |
» | WKAlertAction(title: NSLocalizedString("OK", comment: ""), |
» | style: .Default, |
» | handler: {}) |
» | ]) |
| |
| return |
| } |
| |
| let names = ["RunTimer"] |
| |
| WKInterfaceController.reloadRootControllersWithNames(names, |
| contexts: [track]) |
| } |
Here, you grab two more strings out of the file to use in your interface. If you run the app with your simulator’s language set to French, you’ll see the translation in action as shown in the screenshot.
Just like that, your code for this screen is ready. If NSLocalizedString(_:comment:) doesn’t find what it’s looking for in French—or whatever language the watch is set to—it’ll fall back to your English version. Between localized strings files and storyboards, it’s actually pretty simple to support multiple languages. The only confusing part comes when it’s time to support the right-to-left languages.
As of watchOS 2.1, the platform fully supports right-to-left languages. What does this mean for us as developers? While not as straightforward as supporting multiple left-to-right languages, it’s actually much easier than you might think! When you add a right-to-left language, your user interface flips horizontally to match. This is why instead of “left” and “right” for some interface values, iOS and watchOS use “leading” and “trailing.” For right-to-left languages, “leading” is on the right, not the left!
To control what happens to an interface object, use the setSemanticContentAttribute(_:) method. Passing in values like .ForceLeftToRight helps watchOS to determine your intent. Most of the time, you’ll want your objects to flip, but if you have something that’s purely directional—like our soccer ball example from earlier in this book—it might make sense to force a specific orientation.
For images, you can use UIImage’s imageFlippedForRightToLeftLayoutDirection method to flip an image, but only for right-to-left layouts. Between these two methods, supporting right-to-left languages like Arabic and Hebrew is actually fairly easy! As you can see in this Arabic screenshot, TapALap looks great in either direction.
Localization makes a dramatic impact on your app and opens it to customers you could never have hoped to reach with a single language. It does bring with it a bit of a support burden: once you ship your app with a language, you should be prepared to receive support emails in that language.
Like localization, internationalization expands your app’s reach to a new set of users, but it does so in a much subtler way. Instead of translating the user-interface text, internationalization covers tasks such as using metric units rather than imperial, using the proper date format, and placing events in your users’ time zones. Unlike localization, you can do much of this work yourself, using built-in system classes.
Of all the nice things in Apple’s frameworks, you have been using some of the nicest and didn’t even know it. Remember how you used NSDateFormatter and NSLengthFormatter earlier? Well, guess what? Those classes are already internationalized right out of the box. When using NSDateFormatter to format a date, you get the date formatted in the user’s current locale. 12/25/2016 might be Christmas 2016 in America, but in France that’s written as 25/12/2016, and if you use NSDateFormatter to do your formatting, you won’t have to write even a single line of code to get it right. Similarly, NSLengthFormatter will show the optimal unit for the distance given the user’s locale. Other system-provided NSFormatter subclasses also take the user’s locale into account, meaning that for most cases where you want to display data to the user, an NSFormatter is the way to go.
Sometimes you need to do some internationalization yourself. In TapALap, you will do this on the track configuration screen. To determine if you should show miles or kilometers, you’ll use NSLocale. By using NSLocale’s NSLocaleUsesMetricSystem key, you let the system control which unit of measure you’re using. There are many keys that you can use with NSLocale; read the class documentation for a full list. You encapsulate this key in a lazy property so you need to read it only once. Open TrackConfigurationInterfaceController.swift and add a new property:
| lazy var usesMetric: Bool = { |
| return NSLocale.currentLocale() |
| .objectForKey(NSLocaleUsesMetricSystem) as? Bool ?? false |
| }() |
Now that you have usesMetric, you can determine if you ought to display distance units in miles or kilometers. Modify awakeWithContext(_:) to use this value:
| override func awakeWithContext(context: AnyObject?) { |
| super.awakeWithContext(context) |
| |
| if let receiver = context as? TrackSelectionReceiver { |
| self.trackReceiver = receiver |
| } |
| |
| // Add 1-10 laps for laps picker |
| let lapItems: [WKPickerItem] = (1 ... 10).map { i in |
| let pickerItem = WKPickerItem() |
| pickerItem.title = "(i)" |
| pickerItem.caption = (i == 1) ? "Lap" : "Laps" |
| return pickerItem |
| } |
| |
| lapsPicker.setItems(lapItems) |
| |
| // Add 0.5 - 5 miles for total distance picker |
| var distanceItems: [WKPickerItem] = [] |
| |
| let distanceFormatter = NSLengthFormatter() |
| distanceFormatter.numberFormatter.minimumFractionDigits = 1 |
| distanceFormatter.numberFormatter.maximumFractionDigits = 1 |
| |
| for i in 0.5.stride(to: 5.5, by:0.5) { |
| let pickerItem = WKPickerItem() |
| |
» | if usesMetric { |
» | pickerItem.title = distanceFormatter.stringFromValue(i, |
» | unit: .Kilometer) |
» | } |
» | else { |
» | pickerItem.title = distanceFormatter.stringFromValue(i, |
» | unit: .Mile) |
» | } |
| |
| distanceItems.append(pickerItem) |
| } |
| |
| distancePicker.setItems(distanceItems) |
| |
| // Set values based on initial picker values. |
| lapsPickerDidChange(selectedIndex: 0) |
| distancePickerDidChange(selectedIndex: 0) |
| } |
You aren’t changing the values used, just whether they represent miles or kilometers. To change the values, you need to modify distancePickerDidChange(selectedIndex:) to also inspect the value of usesMetric:
| @IBAction func distancePickerDidChange(selectedIndex i: Int) { |
| if usesMetric { |
| selectedDistance = Double(i + 1) / 2.0 * 1000 |
| } |
| else { |
| // Convert from miles to meters |
| selectedDistance = Double(i + 1) / 2.0 * 1609.34 |
| } |
| } |
Build and run on a watch set to a region that uses the metric system, and you’ll see the units are now in kilometers:
You’re finished! TapALap will now automatically work for people who live in countries that use the metric system. As you can see, internationalization can be complicated, but it will pay off in spades because users from all over the globe have access to your app.
Internationalization and localization are two sides to the same coin. Consider them features of your app: all users can feel at home, like the app was made for just them. To do it right, every piece of text that your users see should be localized, and every non-text string that you present—be it a formatted date, a number, or even the style of quotation marks—should be internationalized. Sure, it’s a lot of work for you, but with an app that supports every language in the App Store and is properly internationalized, you can consider the entire iOS user base as potential customers, and that’s extremely powerful.
3.16.69.52