At the end of last week, I updated two of my existing apps on the App Store to support changing languages within the apps themselves. While I already have this functionality in two of my other apps, the apps I just added this to has a much solid use case, and is arguably something I should have done a long time ago.

Why

In most cases, this won’t be a part of an app’s core functionality; it will merely be an added convenience for a few users at best. This is most certainly true for my two utility apps, Sthlm Travel and Cryptoverter. But there are a few scenarios where this functionality could come in handy.

Accessing localized content

For my other two apps however, I’ve Never and The Blame Game, this could give a substantial boost to user experience. These two apps, which are meant to be used as ice breakers at parties, contains a large amount of localized content for both English and Swedish.

Many Swedish people I know prefer to have their phone language set to English, which means that they will only have access to the app’s content in English. But in company with Swedish friends, they will most likely want to use the Swedish content. So rather than having to change the system language, having a switch in the app is a much more convenient solution.

Testing purposes

Another good use case is to make it easier for your testers. Through Xcode, it’s possible for us developers to deploy our apps in the localization of our choice. We can also deploy the app to the simulator, where we can easily set up multiple simulators - each with its respective localization set. But for the testers, you want a simple and straightforward way for them to switch between languages. They shouldn’t have to deploy their app through Xcode, or change their system language each time.

How

As you may already know, the app per default will choose a localization based on your language settings, either by looking at your system language or the following languages you have prioritized. If none can be found, the default language set by the developer will be used.

Apple doesn’t recommend tampering with this functionality. Instead, they want you to access localized content by using NSBundle.

Since each supported language gets its own language-specific folder with the .lproj extension in a project, (i.e en.lproj for English) accessing language specific content is quite simple:

if let path = Bundle.main.path(forResource: "en", ofType: "lproj"),
    let bundle = Bundle(path: path) {
    print(bundle)
}

To easily handle the switching between languages, I created a utility class:

import Foundation

func LocalizedString(_ key: String) -> String {
    return LanguageHandler.default.localizedString(key)
}

enum PreferredLocalization: String {
    case `default` = "default"
    case english = "en"
    case swedish = "sv"
    case chineseSimplified = "zh-Hans"
    case chineseTraditional = "zh-Hant"
}

extension NSNotification.Name {
    static let PreferredLanguageDidChange = NSNotification.Name(rawValue: "PreferredLanguageDidChange")
}

private let PreferredLanguageKey = "PreferredLanguage"

class LanguageHandler: NSObject {

    static let `default` = LanguageHandler()

    var preferredLocalization: PreferredLocalization {
        set {
            if currentLocalization != newValue {
                currentLocalization = newValue
                updateFromCurrentLocalization()
            }
        }
        get {
            return currentLocalization
        }
    }

    private var currentLocalization: PreferredLocalization = .Default
    private(set) var currentBundle: Bundle = Bundle.main
    private(set) var currentLocale: Locale = Locale.current

    override init() {
        super.init()
        loadCurrentLocalizationIfNeeded()
    }

    private func loadCurrentLocalizationIfNeeded() {
        if let key = UserDefaults.standard.string(forKey: PreferredLanguageKey),
            let localization = PreferredLocalization(rawValue: key) {
            preferredLocalization = localization
        }
        loadCurrentBundleAndLocale()
    }

    private func updateFromCurrentLocalization() {
        loadCurrentBundleAndLocale()
        UserDefaults.standard.setValue(currentLocalization.rawValue, forKey: PreferredLanguageKey)
        NotificationCenter.default.post(name: .PreferredLanguageDidChange, object: nil)
    }

    func localizedString(_ key: String) -> String {
        if currentLocalization == .Default {
            return NSLocalizedString(key, comment: "")
        }
        return currentBundle.localizedString(forKey: key, value: nil, table: nil)
    }

    private func loadCurrentBundleAndLocale() {
        loadCurrentBundle()
        loadCurrentLocale()
    }

    private func loadCurrentBundle() {
        var bundle: Bundle?
        if preferredLocalization != .Default {
            let key = currentLocalization.rawValue
            if let path = Bundle.main.path(forResource: key, ofType: "lproj") {
                bundle = Bundle(path: path)
            }
        }
        currentBundle = bundle ?? Bundle.main
    }

    private func loadCurrentLocale() {
        var locale: Locale?
        if currentLocalization != .Default {
            locale = Locale(identifier: currentLocalization.rawValue)
        }
        currentLocale = locale ?? Locale.current
    }
}

Setting preferred language

To set the preferred language, one only needs to change the preferredLocalization variable of the class.

LanguageHandler.default.preferredLocalization = .swedish

And to add more supported languages, one can simply extend the PreferredLocalization enum, with the raw string value as the language designator (more info can be found here).

case german = "de"

Getting localized content

I went ahead and created a global function, to replace NSlocalizedString, called LocalizedString. This function simply calls the localizedString function of LanguageHandler, which in turn will check the preferred language set, and either use NSLocalizedString or call its current bundle’s localizedString function.

As for fetching other localized content, simply use LanguageHandler.default.currentBundle instead of Bundle.main.

let bundle = LanguageHandler.default.currentBundle
let path = bundle.path(forResource: "resource", ofType: "json")

Being notified on language changes

You may have noticed that this file contains a defined NSNotification, PreferredLanguageDidChange. This notification gets posted when the preferred localization has changed. Listening to such notification could look like this:

NotificationCenter.default.addObserver(forName: .PreferredLanguageDidChange,
                                       object: nil,
                                       queue: OperationQueue.main) { [weak self] (notification) in
                                       // update strings in views
}

In a larger project however, this could get a bit annoying; having to identify all the places where views needs to dynamically update to language changes. To solve this, I chose to extend some work originating from a friend of mine. He originally created subclasses of UILabel, UIButton, and other classes that displays strings, to be able to avoid scenarios where you have to create an IBOutlet just to set a localized string. Since I found his approach to be quite useful, I chose to further extend his work - by adding handling of language changes:

// Credits to Adam Kull for his original work.
import UIKit

@IBDesignable

class LocalizableLabel: UILabel {

    override init(frame: CGRect) {
        super.init(frame: frame)
        registerForLanguageChangeNotification()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        registerForLanguageChangeNotification()
    }

    private func registerForLanguageChangeNotification() {
        NotificationCenter.default.addObserver(forName: .PreferredLanguageDidChange,
                                               object: nil,
                                               queue: OperationQueue.main) { [weak self] (notification) in
                                                self?.textIdentifier = self?.textIdentifier
        }
    }

    @IBInspectable var textIdentifier: String? {
        didSet {
            guard let tid = textIdentifier, !tid.isEmpty else { return }
            super.text = LocalizedString(tid)
        }
    }

    override var text: String? {
        set { if (textIdentifier ?? "").isEmpty { super.text = newValue } }
        get { return super.text }
    }

    deinit {
        NotificationCenter.default.removeObserver(self)
    }
}

By using this LocalizableLabel you only need to set the textIdentifier, either in a Storyboard/Xib or programatically. The rest is taken care of by the label.

Sample project

I’ve put up a sample project on my Github to demonstrate everything in action. As always, I’m open to feedback and suggestions!