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!

Custom progress bar with IBDesignable and IBInspectable

When creating a custom video player, you need to have a component halfway between a UISlider, allowing your to interactively track and seek a particular position in the video, but also show progress continuously while also eventually showing buffering.

Even though there isn’t any component like this directly available in UIKit, it appears it’s fairly easy to make something like this, interactive and fully customizable:

Creating a custom view to display progress and buffer

Let’s start with the basics, we need a subclass of UIView for which we override all possible initializers:

final class CustomProgressBar: UIView {
    // MARK: - Initializers

    init() {
        fatalError("Unsupported initializer init(), use init(frame:) instead")
    }

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

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

    private func commonInit() {
        // TODO
    }
}

Now let’s say we want to add live preview in interface builder, we have to make it @IBDesignable and override the prepareForInterfaceBuilder:

@IBDesignable final class CustomProgressBar: UIView {
    // MARK: - Initializers

    init() {
        fatalError("Unsupported initializer init(), use init(frame:) instead")
    }

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

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

    private func commonInit() {
        // TODO
    }

    // MARK: - Interface builder

    #if INTERFACE_BUILDER
        override func prepareForInterfaceBuilder() {
            super.prepareForInterfaceBuilder()
            commonInit()
        }
    #endif
}

At this stage, nothing appears in interface builder yet, this is normal, we didn’t add anything to our view yet.

The progress view will be composed of several layers: the track in the background, the buffer one level up, the actual progress one level up and finally the knob or thumb.

Let’s create and add our subviews:

private lazy var trackView: UIView = {
    let view = UIView()
    view.translatesAutoresizingMaskIntoConstraints = false
    view.layer.masksToBounds = true
    return view
}()

private lazy var progressView: UIView = {
    let view = UIView()
    view.translatesAutoresizingMaskIntoConstraints = false
    return view
}()

private lazy var bufferView: UIView = {
    let view = UIView()
    view.translatesAutoresizingMaskIntoConstraints = false
    return view
}()

private lazy var thumbView: UIView = {
    let view = UIView()
    view.translatesAutoresizingMaskIntoConstraints = false
    view.layer.masksToBounds = true
    return view
}()

private var trackHeightConstraint: NSLayoutConstraint!
private var thumbHeightConstraint: NSLayoutConstraint!
private var progressViewWidthConstraint: NSLayoutConstraint!
private var bufferViewWidthConstraint: NSLayoutConstraint!

Then in the commonInit method:

private func commonInit() {
    // View must be the same height as intrinsicContentSize
    addConstraint(NSLayoutConstraint(item: self,
                                      attribute: .height,
                                      relatedBy: .equal,
                                      toItem: nil,
                                      attribute: .notAnAttribute,
                                      multiplier: 1.0,
                                      constant: thumbRadius))

    // Configure and add track view
    trackView.backgroundColor = trackColor
    trackView.layer.cornerRadius = trackRadius
    addSubview(trackView)
    addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[trackView]|",
                                                  options: .init(),
                                                  metrics: nil,
                                                  views: ["trackView": trackView]))
    addConstraint(NSLayoutConstraint(item: trackView,
                                      attribute: .centerY,
                                      relatedBy: .equal,
                                      toItem: self,
                                      attribute: .centerY,
                                      multiplier: 1.0,
                                      constant: 0))
    trackHeightConstraint = NSLayoutConstraint(item: trackView,
                                                attribute: .height,
                                                relatedBy: .equal,
                                                toItem: nil,
                                                attribute: .notAnAttribute,
                                                multiplier: 1.0,
                                                constant: trackHeight)
    addConstraint(trackHeightConstraint)

    // Configure and add buffer view
    bufferView.backgroundColor = bufferColor
    trackView.addSubview(bufferView)
    bufferViewWidthConstraint = NSLayoutConstraint(item: bufferView,
                                                    attribute: .width,
                                                    relatedBy: .equal,
                                                    toItem: nil,
                                                    attribute: .notAnAttribute,
                                                    multiplier: 1.0,
                                                    constant: 0.0)
    addConstraint(bufferViewWidthConstraint)
    addConstraint(NSLayoutConstraint(item: bufferView,
                                      attribute: .height,
                                      relatedBy: .equal,
                                      toItem: trackView,
                                      attribute: .height,
                                      multiplier: 1.0,
                                      constant: 0))

    // Configure and add progress view
    progressView.backgroundColor = progressColor
    trackView.addSubview(progressView)
    progressViewWidthConstraint = NSLayoutConstraint(item: progressView,
                                                      attribute: .width,
                                                      relatedBy: .equal,
                                                      toItem: nil,
                                                      attribute: .notAnAttribute,
                                                      multiplier: 1.0,
                                                      constant: 0.0)
    addConstraint(progressViewWidthConstraint)
    addConstraint(NSLayoutConstraint(item: progressView,
                                      attribute: .height,
                                      relatedBy: .equal,
                                      toItem: trackView,
                                      attribute: .height,
                                      multiplier: 1.0,
                                      constant: 0))

    // Configure and add thumb view
    addSubview(thumbView)
    thumbView.alpha = isThumbVisible ? 1.0 : 0.0
    thumbView.backgroundColor = thumbColor
    thumbView.layer.cornerRadius = thumbRadius / 2.0
    addConstraint(NSLayoutConstraint(item: thumbView,
                                      attribute: .centerY,
                                      relatedBy: .equal,
                                      toItem: self,
                                      attribute: .centerY,
                                      multiplier: 1.0,
                                      constant: 0.0))
    addConstraint(NSLayoutConstraint(item: thumbView,
                                      attribute: .width,
                                      relatedBy: .equal,
                                      toItem: thumbView,
                                      attribute: .height,
                                      multiplier: 1.0,
                                      constant: 0.0))
    thumbHeightConstraint = NSLayoutConstraint(item: thumbView,
                                                attribute: .height,
                                                relatedBy: .equal,
                                                toItem: nil,
                                                attribute: .notAnAttribute,
                                                multiplier: 1.0,
                                                constant: thumbRadius)
    addConstraint(thumbHeightConstraint)
    addConstraint(NSLayoutConstraint(item: thumbView,
                                      attribute: .centerX,
                                      relatedBy: .equal,
                                      toItem: progressView,
                                      attribute: .trailing,
                                      multiplier: 1.0,
                                      constant: 0.0))
}

The layout using auto layout, and we observe these all depend on some customizable properties, that we will mark as @IBInspectable so we can modify them directly from Interface Builder inspector:

@IBInspectable var isThumbVisible: Bool = true {
    didSet {
        thumbView.alpha = isThumbVisible ? 1.0 : 0.0
    }
}

@IBInspectable var progress: Float = 0.0 {
    didSet {
        refreshProgress()
    }
}

@IBInspectable var buffer: Float = 0.0 {
    didSet {
        refreshBuffer()
    }
}

@IBInspectable var trackColor: UIColor = UIColor.white {
    didSet {
        trackView.backgroundColor = trackColor
    }
}

@IBInspectable var trackHeight: CGFloat = 10.0 {
    didSet {
        trackHeightConstraint.constant = trackHeight
    }
}

@IBInspectable var trackRadius: CGFloat = 5.0 {
    didSet {
        trackView.layer.cornerRadius = trackRadius
    }
}

@IBInspectable var bufferColor: UIColor = UIColor.black.withAlphaComponent(0.7) {
    didSet {
        bufferView.backgroundColor = bufferColor
    }
}

@IBInspectable var progressColor: UIColor = UIColor.black {
    didSet {
        progressView.backgroundColor = progressColor
    }
}

@IBInspectable var thumbColor: UIColor = UIColor.black {
    didSet {
        thumbView.backgroundColor = thumbColor
    }
}

@IBInspectable var thumbRadius: CGFloat = 20.0 {
    didSet {
        thumbHeightConstraint.constant = thumbRadius
        thumbView.layer.cornerRadius = thumbRadius / 2.0
    }
}

All these @IBInspectable properties have default values that can be overridden in code or directly in storyboard or .xib files.

Every-time the progress or buffer changes, we have to update the constraints of some of subviews:

private func refreshProgress() {
    progressViewWidthConstraint.constant = bounds.width * CGFloat(min(max(progress, 0.0), 1.0))
}

private func refreshBuffer() {
    bufferViewWidthConstraint.constant = bounds.width * CGFloat(min(max(buffer, 0.0), 1.0))
}

Finally, it’s important to override both layoutSubviews to make sure our subviews are property placed when screen size changes (final size, rotations, etc) and the intrinsicContentSize, especially because we want the height to automatically be decided based on our track height and thumb height instead of adding a constraint ourselves:

override func layoutSubviews() {
    super.layoutSubviews()
    refreshProgress()
    refreshBuffer()
}

override var intrinsicContentSize: CGSize {
    let desiredHeight = max(trackHeight, thumbRadius)
    return CGSize(width: UIView.noIntrinsicMetric, height: desiredHeight)
}

Adding interactivity

For it to be interactive, we first need to transform our view superclass from a UIView to a UIControl:

@IBDesignable final class CustomProgressBar: UIControl {

Then, we override the touchesBegan, touchesMoved, touchesCancelled and touchesEnded methods to send basic actions:

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    super.touchesBegan(touches, with: event)
    sendActions(for: .touchDown)
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
    super.touchesMoved(touches, with: event)
    guard let touch = touches.first else {
        return
    }
    let position = touch.location(in: self)
    progress = min(max(Float(position.x / bounds.width), 0.0), 1.0)
    sendActions(for: .valueChanged)
}

override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
    super.touchesCancelled(touches, with: event)
    sendActions(for: .touchUpInside) // Consider this as an ended event instead
}

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    super.touchesEnded(touches, with: event)
    sendActions(for: .touchUpInside)
}

One final touch is to make the touch zone bigger since the knob area can be fairly small:

override func point(inside point: CGPoint, with _: UIEvent?) -> Bool {
    let expandedBounds = bounds.insetBy(dx: min(bounds.width - 44.0, 0), dy: min(bounds.height - 44.0, 0))
    expandedBounds.contains(point)
    return bounds.contains(point)
}

Integration in Interface Builder

To use our custom progress bar, we first drag a new UIView then change its class in the inspector:

We now have new options for customization thanks to the @IBInspectable:

Final code

Final code can be found here: https://gist.github.com/cyrilchandelier/602afdbf23ab02e2e9a77bde7bb2a105

Sort by Name

Simple trick in Xcode, but important nevertheless since keeping things organized is crucial for you and your colleagues.

For instance, you can transform the order of these files:

into this:

by right-clicking on the Views folder and select Sort by Name:

Reveal in Project Navigator shortcut

Xcode 11 buried even more this option allowing to reveal the current file in the Project Navigator:

Right Click / Navigate / Reveal in Project Navigator

Good news, there is also a keyboard shortcut for this action: ⇧ + ⌘ + J

SwiftLint & SwiftFormat

These two tools are now part of my tool belt when creating an iOS project, I don’t see how I can go back working without, and I recommend them for any type of project.

SwiftLint

Automatically enforce Swift style, conventions and best practices. It’s a good idea to just install it when creating a new project and treat all warnings and errors as they come. Everything can be configured or disabled using a .swiftlint.yml file at the root of the project.

Repository: https://github.com/realm/SwiftLint

SwiftFormat

You have a preferred style for formatting your code, your teammates eventually have a different preferred style when formatting their code, and you find yourself spending too much time arguing about the format and/or asking others to update their code during reviews over format issues?

It’s helpful to agree on a common coding style with your team, and it’s even better if you can enforce it. SwiftFormat to the rescue, it enforces the coding style by automatically formatting the code.

I personally installed it for it to run during each build, the overhead is small and I don’t have to think about formatting anymore.

Repository: https://github.com/nicklockwood/SwiftFormat