Software localization
Internationalizing a Full-Stack iOS App with Firebase (Part 2): Firebase i18n
Firebase, Google's serverless backend platform, makes development for small teams and early-phase startups easier and more cost-effective. If you've used Firebase before, you know that it gives you a realtime, NoSQL database (Firestore), cloud storage, and push notifications, among several other backend services. Firebase includes SDKs and packages for all the popular runtimes, and iOS is no exception. Building an iOS Firebase app is like being a full-stack developer, except you focus almost entirely on your UI. But what about Firebase i18n? In this two-part series, we're taking an iOS/Firebase app and internationalizing it so that it can work in multiple languages. We've already tackled much of the UI in part one. Here, we'll round out our app by covering a bit more UI internationalization, look at i18n/l10n in Firebase Firestore, and send push notifications per-language using Firebase Cloud Messaging (FCM).
Our App: Discounter
Here's what we'll build:
When we're done with this article, we'll have this beauty
Our demo app, Discounter, targets price-conscious retail consumers and aggregates city’s coupons, flyers, and sale information for these users in one place. Our users can then browse and search for their favourite products to see if they’ve been discounted. In this series, we'll focus on the Feed screen, which lists recently discounted products in a user's city.
Note » We're starting basically where we left off in the last part. So if you've been coding along with part 1, you can just keep going. If you're starting with us here in part 2, feel free to pick up the starter code for this article on Github.
We've been internationalizing the app so it can work with both English and Arabic. You can choose any languages to work with, of course.
Our starting point
Photo & Icon Credits
Some photos and icons used in the app were sourced. Here’s a list of these sourced assets, along with the awesome people who provided them for free.
- Feed (Menu) Icon by Jardson Almeida, US on The Noun Project
- Search Icon by Landon LLoyd on The Noun Project
- Alert (Notification) Icon by DewDrops on The Noun Project
- Thumb Icon by Ayub Irawan, ID on The Noun Project
- Nike Air Photo by Fachry Zella Devandra on Unsplash
- PlayStation 4 Photo by JESHOOTS.COM on Unsplash
The colour palette used in the mockup was largely derived from the Smashing Magazine Wallpaper, Let’s Get Outside, by Lívia Lénárt.
Recap
In the last article, we largely internationalized the UI, adding a language, localizing the storyboard, and flipping image buttons for right-to-left languages like Arabic and Hebrew.
A Quick Tour of the Code
Let's briefly take a look at the app architecture as it stands.
Our Main.storyboard
The bulk of our UI is defined in the usual Main.storyboard
. Here we have a UITabBarController
for our root navigation, and segues to other, simple controllers that make up our app screens.
Our main screen is controlled by a FeedViewController
. It's a very simple UIViewController
that acts as the data source for our feed's UITableview
.
import UIKit class FeedViewController: UIViewController, UITableViewDataSource { @IBOutlet weak var feedTableView: UITableView! fileprivate var productListener: Product.Listener? fileprivate var products: [Product] = [] { didSet { feedTableView.reloadData() } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) productListener = Product.listenToFeed { [unowned self] in self.products = $0 } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) productListener?.remove() } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return products.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "CELL") as! FeedTableViewCell cell.updateUI(with: products[indexPath.row]) return cell } }
We use Product
, our main model class, to register a listener to our product feed in our viewWillAppear(_:)
. We do this so we can see changes to our feed in realtime. Product
is just using the Firebase Firestore SDK underneath the hood. We deregister our listener in viewWillDisappear(_:)
to avoid memory leaks and unnecessary Firebase database costs.
We'll dive deeper into our Product
model a bit later when we look at internationalizing and localizing our Firestore data. For now, let's take a look at the custom UITableViewCell
that we're using with our FeedViewController
.
import UIKit import SDWebImage class FeedTableViewCell: UITableViewCell { @IBOutlet weak var productNameLabel: UILabel! @IBOutlet weak var storeNameLabel: UILabel! @IBOutlet weak var discountLabel: UILabel! @IBOutlet weak var expiryLabel: UILabel! @IBOutlet weak var priceAfterDiscountLabel: UILabel! @IBOutlet weak var priceBeforeDiscountLabel: UILabel! @IBOutlet weak var productImageView: UIImageView! func updateUI(with product: Product) { productNameLabel.text = product.name storeNameLabel.text = product.store.uppercased() discountLabel.text = product.discount.uppercased() expiryLabel.text = "Expires \(product.expires)".uppercased() priceAfterDiscountLabel.text = product.priceAfterDiscount priceBeforeDiscountLabel.attributedText = strikeThrough(product.priceBeforeDiscount) productImageView.sd_setImage(with: URL(string: product.imageUrl)) } }
Again, this is a pretty bread-and-butter iOS code. We take a Product
object in updateUI(with:)
, do some light transformation to its fields, and connect the resulting values with our cell's views. We're using the popular SDWebImage library for loading our products' network images.
A Quick Currency Fix
In the last article, we introduced the Product
model but we didn't go into its code in too much detail. In Product
, we convert our currency numbers to a string via a global helper function, centsToString(_:)
. You may have encountered this function if you looked at the code in the last article's companion Github repo. centsToString(_:)
uses a NumberFormatter
to produce its currency string, and its logic had a bug in it.
import Foundation func centsToString(_ cents: Int) -> String { let dollars = Double(cents) / 100.0 let formatter = NumberFormatter() formatter.numberStyle = .currency // This next line is added as a fix formatter.currencyCode = "US$" return formatter.string(from: NSNumber(value: dollars))! }
Notice the line formatter.currencyCode = "US$"
above. That line wasn't there in the last article's Github repo, and without it the formatter
would assume the currency of the current locale. That means that if our app user were in Canada, for example, he or she would see currency strings reading something like C$ 200
. Since our app has all its prices in USD, this would, of course, be problematic. The added line above fixes this by strictly forcing the currency to USD.
Displaying Localized Strings in Swift
Let's cover a couple of things we missed in the last article. We know how to internationalize and localize our storyboards, but what about strings that we display through our Swift code?
Well, iOS has a built-in macro, NSLocalizedString(_:comment:)
, which takes a key
parameter used to lookterser
up a translated string in the current locale's Localizable.strings
file. This macro works, but it's a bit inconvenient. NSLocalizedString("foo", comment: "a foo")
is quite a mouthful, and we can have many translated strings in a typical localized app. So we can write a global function that wraps NSLocalizedString
to make our lives easier.
Note » Read more about
NSLocalizedString
in our in-depth article, iOS Localization: The Ultimate Guide to the Right Developer Mindset.
func __(_ key: String) -> String { return NSLocalizedString(key, comment: "") }
Our __(_:)
function is a little terser and more developer-friendly than NSLocalizedString
. We can now use __(myKey)
whenever we want to fetch a translated string.
Note » If you're wondering how to create a
Localizable.strings
file: in the XCode menu bar, go to File > New, select Strings File and click Next. This will create the file and automatically add it to your build settings, ensuring that the file is copied to your app bundle when building the app. Make sure the file is available to your app target and your localizations by clicking on the file and checking the appropriate files in the XCode inspector.
Interpolation in Translated Strings
Sometimes, we have dynamic values in our code that need to be interpolated into a translated string at runtime. Our expiry label string needs a dynamic string within itself, for example.
Our expiry label needs string interpolation
We do this by using the String(format:arguments:)
initializer. Like in other languages' formatting functions, this initializer takes a format
string and an unlimited list of arguments that replace certain format specifiers.
Our expiry string, for example, can be used as a format: String("Expires %@", product.expires)
, where product.expires
is a simple string. Here, the %@
in the format string is a special sequence that tells the initializer that we're going to replace the %@
with a coming string argument: the first argument after the format, which happens to be product.expires
.
Note » Check out the full list of string formats we can use with
String(format:arguments:)
in the official documentation.
This is all good and well, but what about using translated strings? Well, we just combine our __(:)
function with String(format:arguments:)
.
expiryLabel.text = String(format: __("expires"), product.expires)
It's really that simple. Now, in our Localizable.string
files, we can have formatted strings.
// English file "expires" = "Expires %@";
// English file "expires" = "Expires %@";
Localized Uppercase
You may have noticed that in our FeedTableViewCell.swift
file, we were transforming our strings to uppercase using String.uppercased()
. This method will return an uppercase version of our string, but it won't always take into account the current locale's idiosyncrasies. Turkish, for example, capitalizes its i character as İ (note the dot). uppercased()
doesn't cover these cases, so we're better off using the String.localizedUppercase
property to make sure our uppercasing is locale-safe.
Note » There is, of course, a
String.localizedLowercase
counterpart toString.localizedUppercase
.
Here's what our FeedTableViewCell.swift
looks like after our recent changes:
import UIKit import SDWebImage class FeedTableViewCell: UITableViewCell { @IBOutlet weak var productNameLabel: UILabel! @IBOutlet weak var storeNameLabel: UILabel! @IBOutlet weak var discountLabel: UILabel! @IBOutlet weak var expiryLabel: UILabel! @IBOutlet weak var priceAfterDiscountLabel: UILabel! @IBOutlet weak var priceBeforeDiscountLabel: UILabel! @IBOutlet weak var productImageView: UIImageView! func updateUI(with product: Product) { productNameLabel.text = product.name storeNameLabel.text = product.store.localizedUppercase discountLabel.text = product.discount.localizedUppercase expiryLabel.text = String(format: __("expires"), product.expires).localizedUppercase priceAfterDiscountLabel.text = product.priceAfterDiscount priceBeforeDiscountLabel.attributedText = strikeThrough(product.priceBeforeDiscount) productImageView.sd_setImage(with: URL(string: product.imageUrl)) } }
A Closer Look at Our Model
Before we get to internationalizing and localizing our Firestore database, let's see how we connect to it via our Product
model.
import Firebase class Product { fileprivate static let COLLECTION_PATH = "product-feed" typealias OnProductsFetched = (_ products: [Product]) -> Void typealias Listener = ListenerRegistration var name: String var store: String var discount: String var priceAfterDiscount: String var priceBeforeDiscount: String var expires: String var imageUrl: String static func fetchFeed(onSuccess: @escaping OnProductsFetched) { baseQuery().getDocuments { (querySnapshot, error) in if let error = error { print("Error getting documents: \(error)") return } onSuccess(fromDB(documents: querySnapshot!.documents)) } } static func listenToFeed(onChange: @escaping OnProductsFetched) -> ListenerRegistration { return baseQuery().addSnapshotListener { (querySnapshot, error) in guard let documents = querySnapshot?.documents else { print("Error fetching documents: \(error!)") return } onChange(fromDB(documents: documents)) } } fileprivate static var collection: CollectionReference { get { return DB.instance.collection(COLLECTION_PATH) } } fileprivate static func baseQuery() -> Query { return collection.order(by: "expires") } fileprivate static func fromDB(documents: [QueryDocumentSnapshot]) -> [Product] { return documents.map { fromDB(data: $0.data()) } } fileprivate static func fromDB(data: [String: Any]) -> Product { return Product( name: DB.convert(data, "name") ?? "", store: DB.convert(data, "store") ?? "", discount: DB.convert(data, "discount") ?? "", priceAfterDiscount: centsToString( DB.convert(data, "priceAfterDiscountInCents") ?? 0), priceBeforeDiscount: centsToString( DB.convert(data, "priceBeforeDiscountInCents") ?? 0), expires: humanizeDate(date: DB.timestampToDate(data, "expires")), imageUrl: DB.convert(data, "imageUrl") ?? "" ) } init( name: String, store: String, discount: String, priceAfterDiscount: String, priceBeforeDiscount: String, expires: String, imageUrl: String) { self.name = name self.store = store self.discount = discount self.priceAfterDiscount = priceAfterDiscount self.priceBeforeDiscount = priceBeforeDiscount self.expires = expires self.imageUrl = imageUrl } }
fetchFeed(onSuccess:)
and listenToFeed(onChange:)
are static methods, and both will retrieve our product feed from Firestore. The former performs a one-time fetch, while the latter will call its callback closure, onChange
, whenever any write is performed on the product feed Firestore collection.
The rest of our product
methods are helpers that allow us to convert retrieved Firestore objects to Product
objects.
Note » We use
DB.convert(_:_:)
andDB.timestampToDate(_:_:)
to do some type conversion when reading our models. You can peruse the implementation of these functions in the Github repo.
Our FeedTableViewController.swift
uses Product.listenToFeed(onChange:)
to keep its UITableView
in realtime sync with the product-feed
Firestore collection.
Note » You may have noticed our call to
humanizeDate(date:)
above. This function is what converts aDate
object to something liketodayortomorrow. Humanized dates could be the subject of their own post, and if you'd like to see use publish one let us know in the comments below. You can also take a look at the code ofhumanizeDate(date:)
in the GitHub repo.
Internationalizing our iOS App with Firebase: Localizing the Firestore Collection
This is all well and good, but we do have a problem here.
The Arabic version of our app shows English Firestore content
Our app view is localized, but it's pulling content from the wrong locale. Let's fix this by redesigning our database schema in Firestore.
Here's what our product-feed
collection looks like at the moment:
Our product feed Firestore model, as it is now
Of course, we can internationalize this in several ways, and they will all depend on our app's needs. For our app, we can use a simple separation across locales for each data collection.
product-feed-i18n
└── locales
├── ar
| ├── 0qKcByHYIc7Wi7XZSIzH
| | ├── discount: "تخفيض ٢٠٪"
| | ├── name: "نايك اير"
| | └── ...
| └── 57bEpulnmwUGhI2oRJAV
| └── ...
└── en
├── e8xUGV743gcGfOUKJvx4
| ├── discount: "20% off"
| ├── name: "Nike Air"
| └── ...
└── rOExjrtGXXiaK0DsRCqD
└── ...
Instead of our documents nesting directly in the product-feed
collection, our new product-feed-i18n
collection has them broken up per-locale. We have an empty locales
document that allows us to add a collection for each locale under it. Our documents are then placed in each of these collections with their translations.
Updating Our Model
Since our product feed schema has changed, we need to update our app code to access the localized products. Thankfully, this is an easy fix.
import Firebase class Product { fileprivate static let COLLECTION_PATH = "product-feed-i18n/locales/{locale}" // ... fileprivate static var collection: CollectionReference { get { return DB.instance.collection( COLLECTION_PATH.replacingOccurrences( of: "{locale}", with: Locale.current.languageCode! ) ) } } // ... }
We update our COLLECTION_PATH
so that we document our schema change at the top of our file, in its configuration. Then, we do a simple string replacement to get the feed collection corresponding to the user's current language. Nothing else in our code has to change.
Et viola
Note » In production, we would have to account for our user's current language not being supported in our Firestore database, and have an appropriate fallback.
Sending Localized Push Notifications with Firebase
One of the best things about Firebase is its Firebase Cloud Messaging (FCM) service. FCM allows sending messages to one, some, or all users of our app without the hassle of connecting to Apple's APNS servers ourselves. Doing this through the Firebase Console couldn't be easier.
Note » To test this, you'll need to setup push notifications and test on a physical iOS device.
Sending a targeted notification message via the Firebase Console
In the Firebase console, we navigate to Grow > Cloud Messaging. We should arrive at the Cloud Messaging screen defaulted to the Notifications tab. From there, we click the New notification button. Now, we can enter our notification copy in the Notification text field and click the Target label to open that section. In the Target section, we select our target app (iOS in our case), and click the and button to add another target constraint. We select Language from the dropdown menu, leave Is in as the conditional operator, and select a language we want to target. That's basically it. We can now Review and Publish our message. When we do so, the message will be received only by users who installed and opened the app in the language we just targeted.
That's All, Folks
That about covers our first journey in internationalizing an iOS and Firebase app. We finished up internationalizing our UI, localized our Firestore database, and learned how to send language-specific push notifications through FCM.
Note » You can get the completed code for this article on Github.
Are you working on an iOS app and internationalizing and localizing it for audiences in multiple locales? Well, if you're looking for a feature-rich, professional localization solution, Phrase may come in handy. Phrase works with iOS localization XLIFF files natively. It tracks translation versions so that your translators can easily go back to older ones. It’s also built with collaboration in mind, allowing you to add unlimited team members to your project and to integrate with your Slack team. You can even do over-the-air translation updates with Phrase, so your translations can get to your users immediately without waiting for an app update. Check out Phrase's full product set, and take it for a spin for free.
I hope you've enjoyed this foray into internationalizing a full-stack iOS app. Stay curious, friends 🤓