Let’s immediately localize our projects

New project? Whether you need it or not, start localizing immediately. It does not matter that your app is only in English at launch, sooner or later, you’ll need to internationalize your app. So you’ll only see the benefits later, when you are app picks up and you suddenly find the need to translate it.

Good news, with a few simple tricks that can be started when the project is created or soon after:

  • you won’t have to open all your screens one by one to find what needs to be localized, it’s already done
  • you will find all localized strings in your catalog, ready to be sent to somebody for translation
  • bonus: if you work for a company, impress your boss by giving a ridiculously low estimate for how much time it will take to localize, the work was done incrementally, and it’s not going to be a huge project to support a new language

Add a String Catalog to your project

In the file explorer of Xcode, right-click in your Resources or Supporting Files folder, click “New File…”, and add a String Catalog called “Localizable”.

That’s it, you are ready to localize.

Adopt a naming convention for your keys

Keep it simple, my keys are always structured the same, with a bit of consistency, you will make it easy to find what you want, and maintain your project.

The convention I recommend is:

  • the first segment is the name of the screen
  • each segment gets more specific about where the string is going to be used and what it represents
  • each segment of your key is written using snake_case
  • segments are separated by a dot .

For example:

  • “editor.title” represents the title of the Editor screen
  • “editor.add_frame_button.title” represents the title within

Write a shorthand to transform a key into a localized string

Writing NSLocalizedString every time you need it and adding a comment isn’t. It’s dead simple, start by creating a new String+i18n.swift file.

Add a basic extension to transform a string into a localized string:

import Foundation

extension String {
    var i18n: String { NSLocalizedString(self, comment: "") }
}

And then to use it in code:

func viewDidLoad() {
    super.viewDidLoad()

    // i18n
    title = "editor.title".i18n // Editor
    addFrameButton.setTitle("editor.add_frame_button.title".i18n, forState: .normal) // Add Frame
}

And that’s pretty much it. Never hardcode an interface string and always use your shorthand function to localizable each and every string, it’s an overhead of approximately 30 seconds per screen that will benefit you in the long run. This will work with UIKit & SwiftUI.

Building a simple feature flag system in Swift with Firebase Remote Config

In this post, we will see how we can create a simple feature flag system in Swift leveraging the Firebase Remote Config.

Feature flags (also sometimes called feature gates) are a simple mechanism that allows for enabling/disabling a feature at runtime. Here are some interesting use cases of feature flags:

  • only enable a feature for certain users meeting certain criteria: for example, I want to enable this feature, but only for users who live in the United States or have their phone language set to English
  • develop a feature and merge your code to the main branch freely as you know it will not be available to the user until you are ready to enable the feature
  • disable a feature remotely, without having to re-deploy your clients (and in the case of iOS re-submit a new version and wait for users to update their apps)

Before we begin

The following assumes that you already have Firebase installed and configured in your project, if not, please follow the instructions at https://firebase.google.com/docs/ios/setup).

In the Firebase console, let’s create our first dynamic configuration to represent a feature gate. I’m going to call this one enable_learn_mode_v2. For this, let’s go to the Firebase console, then Remote Config, and click on “Add parameter”. Set the key of the feature flag in the “Parameter name (key)” field, the Data type is going to be Boolean, and the default value false.

Remote configuration

A remote configuration is the first prerequisite to creating a dynamic feature flag system. We are going to create an abstraction that allows us to retrieve a dynamic configuration, and use Firebase Remote Config as its first implementation, this way we can easily migrate to another technology when we need to.

First, let’s create an enum that will encapsulate our different configuration keys:

// Configuration.swift

import Foundation

// MARK: - Configuration
enum Configuration {
    case custom(key: String)
    /* insert future configuration keys here */
    
    var key: String {
        switch self {
        case let .custom(customKey):
            return customKey
        }
    }
}

We can now create a ConfigurationProvider protocol to define how we will want to use the remote configuration:

// ConfigurationProvider.swift

import Foundation

// MARK: - ConfigurationProvider
protocol ConfigurationProvider {
    func boolean(for configuration: Configuration) -> Bool
    func double(for configuration: Configuration) -> Double
    func integer(for configuration: Configuration) -> Int
    func string(for configuration: Configuration) -> String?
    func string(for configuration: Configuration, defaultValue: String) -> String
}

Most components in our app will only need to use this ConfigurationProvider, but the app itself will need to make sure it refreshes the configuration at launch, let’s create a ConfigurationManager protocol for that:

// ConfigurationManager.swift

import Foundation

// MARK: - ConfigurationManager
protocol ConfigurationManager: ConfigurationProvider {
    func refresh() async throws
}

And now that we have all the building blocks, we can create our implementation using Firebase:

// FirebaseConfigurationManager.swift

import Firebase

// MARK: - FirebaseConfigurationManager
final class FirebaseConfigurationManager: ConfigurationManager {
    enum Exception: Error {
        case unknownFetchError
    }
    
    // MARK: - Remote config management
    func refresh() async throws {
        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
            remoteConfig.fetch { (status, error) in
                switch status {
                case .noFetchYet, .throttled, .success:
                    continuation.resume(returning: ())
                case .failure:
                    continuation.resume(throwing: error ?? Exception.unknownFetchError)
                @unknown default:
                    assertionFailure("Unsupported case when refreshing the Firebase remote configuration")
                }
            }
        }
    }
    
    // MARK: - Value processing
    func boolean(for configuration: Configuration) -> Bool {
        remoteConfig[configuration.key].boolValue
    }
    
    func double(for configuration: Configuration) -> Double {
        remoteConfig[configuration.key].numberValue.doubleValue
    }
    
    func integer(for configuration: Configuration) -> Int {
        remoteConfig[configuration.key].numberValue.intValue
    }
    
    func string(for configuration: Configuration) -> String? {
        remoteConfig[configuration.key].stringValue
    }
    
    func string(for configuration: Configuration, defaultValue: String) -> String {
        string(for: configuration) ?? defaultValue
    }
    
    // MARK: - Dependencies
    private let remoteConfig: RemoteConfig = RemoteConfig.remoteConfig()
}

It’s now time to initialize our configuration manager. The AppDelegate is a good place to initialize and refresh the configuration, and you may only want to really access the app once this step is done:

// AppDelegate.swift

import Firebase
import UIKit

// MARK: - AppDelegate
final class AppDelegate: UIResponder, UIApplicationDelegate {
    private lazy var configurationManager: ConfigurationManager = FirebaseConfigurationManager()
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Initialize Firebase
        FirebaseApp.configure()
        
        // Refresh the remote configuration
        Task {
            do {
                try await configurationManager.refresh()
                // only now the app really ready to start
            } catch {
                print("Error: failed to refresh the remote configuration.")
            }
        }

        return true
    }
    
    ...
}

Providing feature gates

In a similar way, we are going to create an abstraction for the feature flag system that uses our ConfigurationProvider as a data source.

We start with an enum that will encapsulate our feature flag keys. The string associated values will be the configuration keys:

// FeatureGate.swift

import Foundation

// MARK: - FeatureGate
enum FeatureGate: String {
    case enableLearnModeV2 = "enable_learn_mode_v2"
    
    var key: String { rawValue }
}

And as you will see, the feature gate provider implementation is quite simple:

// FeatureGateProvider.swift

import Foundation

// MARK: - FeatureGateProvider
protocol FeatureGateProvider {
    func isGateOpen(_ featureGate: FeatureGate) -> Bool
}

// MARK: - DefaultFeatureGateProvider
final class DefaultFeatureGateProvider: FeatureGateProvider {
    // MARK: - Initializers
    init(configurationProvider: ConfigurationProvider) {
        self.configurationProvider = configurationProvider
    }
    
    // MARK: - Gate management
    func isGateOpen(_ featureGate: FeatureGate) -> Bool {
        configurationProvider.boolean(for: .custom(key: featureGate.key))
    }
    
    // MARK: - Dependencies
    private let configurationProvider: ConfigurationProvider
}

Usage

We now have a FeatureGateProvider class we can inject in our code (view controllers, view models, presenters, etc.) to determine if a feature is enabled or not. Then it’s a simple matter of writing a bit of conditional code:

if featureGateProvider.isGateOpen(.enableLearnModeV2) {
  // new behavior to only be active when the gate is open
} else {
  // default behavior for when the gate is closed
}

Unit testing

Since we have created protocols for most things, it’s going to be very easy to mock our feature flag system in unit tests:

// ConfigurationManagerMock.swift

@testable import FeatureGateSystem

// MARK: - ConfigurationManagerMock
final class ConfigurationManagerMock: ConfigurationManager {
    private(set) var refreshCallCount: Int = 0
    func refresh() async throws {
        refreshCallCount += 1
    }
    
    var booleanOverride: Bool = false
    private(set) var booleanCallCount: Int = 0
    func boolean(for configuration: Configuration) -> Bool {
        booleanCallCount += 1
        return booleanOverride
    }
    
    var doubleOverride: Double = 0.0
    private(set) var doubleCallCount: Int = 0
    func double(for configuration: Configuration) -> Double {
        doubleCallCount += 1
        return doubleOverride
    }
    
    var integerOverride: Int = 0
    private(set) var integerCallCount: Int = 0
    func integer(for configuration: Configuration) -> Int {
        integerCallCount += 1
        return integerOverride
    }
    
    var stringOverride: String? = nil
    private(set) var stringCallCount: Int = 0
    func string(for configuration: Configuration) -> String? {
        stringCallCount += 1
        return stringOverride
    }
    
    private(set) var stringWithDefaultValueCallCount: Int = 0
    func string(for configuration: Configuration, defaultValue: String) -> String {
        stringWithDefaultValueCallCount += 1
        return defaultValue
    }
}
// ConfigurationProviderMock.swift

@testable import FeatureGateSystem

// MARK: - ConfigurationProviderMock
final class ConfigurationProviderMock: ConfigurationProvider {
    var booleanOverride: Bool = false
    private(set) var booleanCallCount: Int = 0
    func boolean(for configuration: Configuration) -> Bool {
        booleanCallCount += 1
        return booleanOverride
    }
    
    var doubleOverride: Double = 0.0
    private(set) var doubleCallCount: Int = 0
    func double(for configuration: Configuration) -> Double {
        doubleCallCount += 1
        return doubleOverride
    }
    
    var integerOverride: Int = 0
    private(set) var integerCallCount: Int = 0
    func integer(for configuration: Configuration) -> Int {
        integerCallCount += 1
        return integerOverride
    }
    
    var stringOverride: String? = nil
    private(set) var stringCallCount: Int = 0
    func string(for configuration: Configuration) -> String? {
        stringCallCount += 1
        return stringOverride
    }
    
    private(set) var stringWithDefaultValueCallCount: Int = 0
    func string(for configuration: Configuration, defaultValue: String) -> String {
        stringWithDefaultValueCallCount += 1
        return defaultValue
    }
}
// FeatureGateProviderMock.swift

@testable import FeatureGateSystem

// MARK: - FeatureGateProviderMock
final class FeatureGateProviderMock: FeatureGateProvider {
    var isGateOpenOverride: [FeatureGate: Bool] = [:]
    private(set) var isGateOpenCallCount: Int = 0
    func isGateOpen(_ featureGate: FeatureGate) -> Bool {
        isGateOpenCallCount += 1
        return isGateOpenOverride[featureGate] ?? false
    }
}

Enabling isolated testability for your iOS project

By default with iOS and Swift, the test target runs your actual application in a simulator, but this can be a problem because you usually don’t want to run the entire launch sequence when running unit tests. The launch sequence may trigger analytics events, or pull data from an API for no good reason since we are only interested in running our tests in the most isolated environment possible.

To avoid this situation, we are going to tell Xcode to launch our application from a dummy entry point, only for test purposes. This testing launch sequence will do… nothing, and that’s the point!

Step 1: Removing existing @main modifiers

By default, Xcode uses a the @main annotation to know how to launch the application. This annotation is automatically added by the Xcode project template in the AppDelegate. In order to change the entry point of the application during unit tests, let’s remove the annotation altogether. To do that, change your `AppDelegate.swift` from:

// AppDelegate.swift

@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
    ...
}

to:

// AppDelegate.swift

final class AppDelegate: UIResponder, UIApplicationDelegate {
    ...
}

Step 2: main.swift

Xcode now needs a way to know what the entry point of the application is, if not using the @main annotation, you use UIApplicationMain function from a special file named main.swift. This file is optional and doesn’t exist by default in your app, so let’s create it:

// main.swift

import UIKit

let appDelegateClass: AnyClass = NSClassFromString("TestingAppDelegate") ?? AppDelegate.self
UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, NSStringFromClass(appDelegateClass))

With this code, we say: try to use the TestingAppDelegate as a starting point for the application, but if it does not exist (and this file will only exist in the test target), use the standard AppDelegate.

Step 3: Creating test utilities

Let’s fill in the blanks by creating the TestingAppDelegate and associated classes. This special AppDelegate will not do anything besides connect to a TestingSceneDelegate that itself will do nothing besides adding a dummy view controller as its root.

In your test target, create a new Test Utilities folder and add the following 3 new files to it:

  • TestingAppDelegate.swift
  • TestingSceneDelegate.swift
  • TestingViewController.swift
// TestingAppDelegate.swift

// MARK: - TestingAppDelegate
@objc(TestingAppDelegate)
final class TestingAppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        return true
    }

    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        let sceneConfiguration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
        sceneConfiguration.delegateClass = TestingSceneDelegate.self
        sceneConfiguration.storyboard = nil
        return sceneConfiguration
    }
}
// TestingSceneDelegate.swift

import UIKit

// MARK: - TestingSceneDelegate
final class TestingSceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = scene as? UIWindowScene else {
            return
        }
        window = UIWindow(windowScene: windowScene)
        window?.rootViewController = TestingViewController()
        window?.makeKeyAndVisible()
    }
}
// TestingViewController.swift

import UIKit

// MARK: - TestingViewController
final class TestingViewController: UIViewController {
    override func loadView() {
        let label = UILabel()
        label.text = "Running unit tests..."
        label.textAlignment = .center
        label.textColor = .white
        label.backgroundColor = .black

        view = label
    }
}

Step 4: work around the Xcode caching issue

Since the introduction of SceneDelegate in iOS 13, testing was made a tiny bit more complicated due to the iOS operating system caching the SceneDelegate across sessions. This is a problem because switching from the actual app running in the simulator to the unit tests running in the same simulator results in Xcode being confused and using the cached version instead the one we decided to use in AppDelegate or TestingAppDelegate.

To bypass this issue, you can manually kill the app and remove it from the app switcher in between sessions, but the easiest way to always try to run the tests in a different simulator (eg: I used to run the app in a iPhone 14 Pro simulator, but the tests in an iPhone 14). Unfortunately, this is still error-prone and you always end up running the wrong scheme in the wrong simulator.

But I recently came across this solution that helped solve this problem by removing all cached scene configurations in the testing AppDelegate to ensure the configuration declared TestingSceneDelegate is used. We need to modify our TestingAppDelegate to add the following code:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Remove any cached scene configurations to ensure that TestingAppDelegate.application(_:configurationForConnecting:options:)
    // is called and TestingSceneDelegate will be used when running unit tests.
    // NOTE: THIS IS PRIVATE API AND MAY BREAK IN THE FUTURE!
    for sceneSession in application.openSessions {
        application.perform(Selector(("_removeSessionFromSessionSet:")), with: sceneSession)
    }

    return true
}

As noted, this is using a private API! Normally, you would want to avoid using private APIs because Apple will systematically detect them during App Review and reject your submission to the App Store. But in this case, the private API is used in the unit tests target, and since this code never reaches the App Store, it is 100% safe to use.

It’s done

Now when running the unit tests, the app that launches in the simulator isn’t your full app, it’s this nicely isolated dummy app that simply says “Running unit tests…”.

Happy testing!

ISO8601DurationFormatter

I really had no idea what formatted strings like PT0S, P0D or PT5M30S meant when I first encountered them… And I am not alone, almost all developers who join the team ask the same question: what are these?

Well, did you know that the ISO8601 standard, commonly used to format dates, also has a duration component?

From https://en.wikipedia.org/wiki/ISO_8601#Durations:

Even though the Foundation framework in Swift offers the ISO8601DateFormatter, it obviously doesn’t support durations and since we needed to serialize/deserialize durations for a project, I ended up writing a small library that does just that.

Github repo: https://github.com/cyrilchandelier/ISO8601DurationFormatter

iOS and the status bar

Since iOS 7, there is this UIViewControllerBasedStatusBarAppearance or “View controller-based status bar appearance” option in Info.plist that aims to manage the way status bar appearance is set across the app.

Typical answers on Stack Overflow to questions regarding status bar text color not changing to white when using the UIApplication setStatusBarStyle: method or not disappearing when hiding will say:

Just set the “View controller-based status bar appearance” option to NO in your Info.plist.

That’s a mistake. By doing this, you are turning off the feature introduced in iOS 7. Even if I agree that this is convenient, I could not encourage you more to work with UIViewControllerBasedStatusBarAppearance set to YES.

Here are some things I learned trying to get it to work:

  1. The “Status bar style” and “Status bar is initially hidden” are still used during launch screen.
  2. If a view controller is not contained in a navigation controller and if you want to have a status bar with text colored in white, then you have to override the preferredStatusBarStyle method to say that you want the light content status bar style
  3. If you are using navigation controllers, the previous method is ignored so you should use [[UINavigationBar appearance] setBarStyle:UIBarStyleBlack];, status bar style is automatically set light content in this case
  4. To hide status bar, there is also this “prefersStatusBarHidden” method that you can override
  5. The overridden methods are called when  your view controller is first displayed, but you can force iOS to update status bar settings by calling [self setNeedsStatusBarAppearanceUpdate]; at runtime

A good example of something you can’t achieve with UIViewControllerBasedStatusBarAppearance set to NO: is getting the status bar to white text color when opening a MFMailComposeViewController; even with status bar default color globally set, the status bar stays written in black. To bypass that, you must change the option value to YES, and you can, for instance, create a category like this one:

#import "MFMailComposeViewController+StatusBarStyle.h"
@implementation MFMailComposeViewController (StatusBarStyle)
#pragma mark - Status bar management
- (UIStatusBarStyle)preferredStatusBarStyle
{
    return UIStatusBarStyleLightContent;
}
- (UIViewController *)childViewControllerForStatusBarStyle
{
    return nil;
}
@end

Part of the credit goes to this post: https://coderwall.com/p/hitv1q/mastering-ios-7-uistatusbarstyle