Software localization

Internationalizing a Full-Stack iOS App with Firebase (Part 2): Firebase i18n

Your step-by-step guide to Firebase i18n: How to internationalize Firebase Firestore data and push notifications to readers of a specific language.
Software localization blog category featured image | Phrase

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:

Finished demo app | Phrase

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.

Demo app | Phrase

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.

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.

Main.storyboard | Phrase

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.

expiry label without string interpolation | Phrase

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 to String.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(_:_:) and DB.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 a Date object to something like today or tomorrow. 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 of humanizeDate(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.

Arabic version with English Firestore content | Phrase

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:

Product feed Firestore model | Phrase

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.

Demo app with updated modal | Phrase

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.

Targeted notification message via the Firebase Console | Phrase

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 🤓