Alert queuing with windows

November 6th, 2019
#ui #alerts

Being British, queuing is essential to me. I take every opportunity I can get to queue: posting a package ✅, paying for my groceries ✅, fleeing a burning building ✅, etc. So every time I need to present a sequence of alerts, I come up against the uncomfortable truth that UIAlertController doesn't care as much about queuing as I do 😔.

Photo of people standing in truly glorious queuing

This article is a look at how UIAlertController can be made a little more British through embracing a strong queuing etiquette.

Let's get queuing 🇬🇧

A queue is a first-in, first-out data structure so that the oldest element is the first to be removed. Swift doesn't come with a built in queue type so lets build one:

struct Queue<Element> {
    private var elements = [Element]()

    // MARK: - Operations

    mutating func enqueue(_ element: Element) {
        elements.append(element)
    }

    mutating func dequeue() -> Element? {
        guard !elements.isEmpty else {
            return nil
        }

        return elements.removeFirst()
    }
}

The above class is a relatively trivial generic queue implementation that allows only two operations - enqueue and dequeue.

When queuing alerts, it's important to ensure that all alerts are queued on the same instance. To achieve this, we need a specialised singleton class that will control access to the queue and be the central component for all alert presentation logic:

class AlertPresenter {
    private var alertQueue = Queue<UIAlertController>()

    static let shared = AlertPresenter()

    // MARK: - Present

    func enqueueAlertForPresentation(_ alertController: UIAlertController) {
        alertQueue.enqueue(alertController)

        //TODO: Present
    }
}

In the above class, AlertPresenter holds a private queue specialised for UIAlertController elements and a method for enqueuing alerts. But as of yet, no way to present the queued alert. Before we can implement a presentation method, let's look at how alerts are normally presented:

let alertController = ...

present(alertController, animated: true, completion: nil)

UIAlertController (which is a subclass of UIViewController) is presented modally like any other view-controller by calling present(_:animated:completion:) on the presenting view-controller. A consequence of queuing alerts in AlertPresenter is that it brokes the usual alert presentation flow. As AlertPresenter isn't a subclass of UIViewController we can't use it directly for presenting. Instead, we need to get a view-controller to present from. As well as requiring a view-controller to be injected into AlertPresenter, our indirect alert presentation also exacerbates an existing subtle issue with presenting alerts - simultaneous navigation events (presentation/dismissal) occurring at the same time resulting in one of those events being cancelled and the following error being generated:

Screenshot showing a navigation collision between two view-controllers preventing one of the events from occuring

Without AlertPresenter being involved, the alert would be presented directly from the view-controller, allowing that view-controller to prevent other navigation events occurring until after the alert is dismissed. However, by queueing the alert, the view-controller has no way of knowing when it will be shown so no way of knowing when it should or shouldn't prevent other navigation events.

While it's possible for AlertPresenter to query the topmost view-controller and determine if it is in the process of presenting or being dismissed, doing so requires logic that gets messy quickly 🤢. Instead of having to accommodate events happening on the navigation stack that AlertPresenter doesn't control, we can raise AlertPresenter above all navigation concerns by using a dedicated UIWindow instance (with a dedicated view-controller) to present the queued alerts from. As the navigation stack in one window is independent of any other window's navigation stack, an alert can be presented on its window at the same time as a view controller is being pushed on its window without navigation collisions 🥳.

Before delving into how to use a dedicated window to present alerts, let's get to know windows better.

If you are comfortable with how UIWindow works, feel free to skip ahead.

Getting to know windows 💭

UIWindow is a subclass of UIView that acts as the container for an app's visible content - it is the top of the view hierarchy. All views that are displayed to the user need to be added to a window. An app can have multiple windows, but only windows that are visible can have their content displayed to the user - by default windows are not visible. Multiple windows can be visible at once. Each window's UI stack is independent of other window's UI stacks. Where a window is displayed in regards to other visible windows is controlled by setting that window's windowLevel property. The higher the windowLevel the nearer to the user that window is. UIWindow has three default levels:

  1. .normal
  2. .statusBar
  3. .alert

With .alert > .statusBar > .normal. If a more fine-grain level of control is needed it's possible to use a custom level:

window.windowLevel = .normal + 25

As of iOS 13: .alert has a raw value of 2000, .statusBar has a raw value of 1000 and .normal has a raw value of 0.

Where two or more windows have the same level, their ordering is determined by the order they were made visible in - the last window made visible is nearest one to the user.

It's unusual to directly add subviews to a window instead each window should have a root view-controller who's view is used as the window's initial subview.

As well as displaying content, UIWindow is also responsible for forwarding any events (touch, motion, remote-control or press) to interested parties in it's responder chain. While all touch events are forwarded to the responder chain of the window that the event occurred on, events that are outside of the app's UI such as motion events or keyboard entry, are forwarded to the key window. Only one window can be key at any one given time (which window is key can change throughout the lifetime of the app).

An iOS app needs at least one window, a reference to this window can be found in the app delegate:

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    //Omitted methods
}

If you're using storyboards to layout your interface, then most of the work of setting up this window is happening under the hood. When the app is launched a UIWindow instance is created that fills the screen. This window is then assigned to the window property (declared in the UIApplicationDelegate protocol), configured with the view-controller declared as the storyboard entry point from the project's main storyboard as it's rootViewController and finally the window is made key and visible.

If you are not using storyboards you can better see this setup:

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    // MARK - AppLifecycle

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        window = UIWindow()
        window?.rootViewController = YourViewController()
        window?.makeKeyAndVisible()

        return true
    }
}

Using windows

As this new window will only display alerts, let's subclass UIWindow for this single purpose:

class AlertWindow: UIWindow {
    // MARK: - Init

    init(withAlertController alertController: UIAlertController) {
        super.init(frame: UIScreen.main.bounds)

        rootViewController = alertController

        windowLevel = .alert
    }

    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("Unavailable")
    }

    // MARK: - Present

    func present() {
        makeKeyAndVisible()
    }
}

In the above AlertWindow class an alert is passed into the init'er and set to the window's rootViewController, the window is then configured to have a .alert window level - this will put it above the app's main window (which by default has a .normal window level).

If you create an instance of AlertWindow and make it visible, you will notice that it's not being presented with an animation - it just appears which feels weird. A window's root view-controller can not be animated on-screen so to keep the alert's animation we need that alert to be presented from an intermediate view-controller which can be the window's root view-controller:

class HoldingViewController: UIViewController {
    private let alertController: UIAlertController

    // MARK: - Init

    init(withAlertController alertController: UIAlertController) {
        self.alertController = alertController
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // MARK: - ViewLifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .clear
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        present(alertController, animated: true, completion: nil)
    }
}

The above HoldingViewController class only has one responsibility - presenting an alert once viewDidAppear(_:) has been called.

Trying to present an alert earlier will cause an animation error due to the HoldingViewController instance having not finished its own "animation" on screen. While (at the time of writing - iOS 13) this doesn't seem to affect the actual presentation of the alert, waiting for HoldingViewController to be presented before attempting another presentation ensures that the error isn't produced.

Lets use HoldingViewController:

class AlertWindow: UIWindow {

    // MARK: - Init

    init(withAlertController alertController: UIAlertController) {
        super.init(frame: UIScreen.main.bounds)

        rootViewController = HoldingViewController(withAlertController: alertController)

        windowLevel = .alert
    }

    //Omitted methods
}

In the above class, instead of the UIAlertController instance being set directly as the rootViewController it is used to create an HoldingViewController instance which is set as the window's rootViewController.

Each AlertWindow instance is intended to be single-use - to present one alert. This single-use nature allows for its mere presence to be the determining factor in whether a queued alert should be presented or not:

class AlertPresenter {
    //Omitted properties

    private var alertWindow: AlertWindow?

    // MARK: - Present

    func enqueueAlertForPresentation(_ alertController: UIAlertController) {
        alertQueue.enqueue(alertController)

        showNextAlertIfPresent()
    }

    private func showNextAlertIfPresent() {
        guard alertWindow == nil,
            let alertController = alertQueue.dequeue() else {
                return
        }

        let alertWindow = AlertWindow(withAlertController: alertController)
        alertWindow.present()

        self.alertWindow = alertWindow
    }
}

With the above changes, alertWindow holds a reference to the window that is being used to present the alert. Inside showNextAlertIfPresent() if alertWindow is nil and there is a queued alert then a new AlertWindow instance is created, presented and assigned to alertWindow. Making the window key and visible sets off the chain of activity that results in the alert being animated on screen.

The example so far while functional is limited - it can present only one alert. AlertPresenter needs to be informed when an alert has been dismissed so it can move onto the next alert.

It turns out knowing when an alert has been dismissed is one of the trickier things to know about in an iOS app 😕. I had to work through a number of different solutions before I got somewhere I was happy:

  1. My first attempt involved subclassing UIAlertController and overriding viewDidDisappear(_:) with a call to AlertPresenter - everyone would then use this subclass instead of UIAlertController directly. However, this approach is explicitly warned against in the documentation for UIAlertController:

Screenshot of UIAlertController documentation showing the warning

👎

  1. My second attempt involved requiring each developer to explicitly call AlertPresenter from inside each UIAlertAction closure. However, this approach puts too much of a burden on each developer to remember to include those calls in each and every action. A missed call from any action closure (that was substantially triggered) would cause the queue to be jammed from that alert until the app was killed. This is too easy a requirement to forget when writing or reviewing 👎.

  2. My third attempt involved using a view model to abstract the creation of UIAlertController instances to a factory method where a call to AlertPresenter could then be injected into each UIAlertAction closure (during the conversion from view model to UIAlertController instance). However, this approach would require a lot of custom code to be written, maintained and tested - using UIAlertController directly would avoid all effort 👎.

Tricky, tricky 🤔.

Instead of thinking about how to get UIAlertController to tell us about what was happening to it, I decided to start thinking about what impact UIAlertController had on its surroundings. UIViewController has a great suite of methods for when it will appear and disappear - these appearance events can tell us what has happened to the presented alert. When an alert is dismissed, it calls func dismiss(animated:completion:) on the view-controller it was presented from. We can hook into this behaviour to inform AlertWindow about the dismissal:

protocol HoldingDelegate: class {
    func viewController(_ viewController: HoldingViewController, didDismissAlert alertController: UIAlertController)
}

class HoldingViewController: UIViewController {
    //Omitted properties

    weak var delegate: HoldingDelegate?

    // Omitted methods

    override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
        //Called when a UIAlertController instance is dismissed
        super.dismiss(animated: flag) {
            completion?()

            self.delegate?.viewController(self, didDismissAlert: self.alertController)
        }
    }
}
👍

You may be thinking: "Why is func dismiss(animated:completion:) being called on a view-controller that isn't being dismissed instead of viewDidAppear(_:) as it is actually reappearing"?. The same thought occurred to me, and this mismatch between expectations vs reality left me a little uncomfortable 😒. However, in the end, I decided that being able to use UIAlertController without having to care about the queue outweighed my discomfort. If Apple changes this to better match the behaviour of other UIViewController instances, modifying this solution to handle both cases will be trivial.

When an alert has been dismissed, the presenting AlertWindow instance has served its purpose and can itself be dismissed:

class AlertWindow: UIWindow, HoldingDelegate {
      //Omitted properties

      // MARK: - Init

      init(withAlertController alertController: UIAlertController) {
          super.init(frame: UIScreen.main.bounds)

          let holdingViewController = HoldingViewController(withAlertController: alertController)
          holdingViewController.delegate = self

          rootViewController = holdingViewController

          windowLevel = .alert
      }

      //Omitted other methods

      // MARK: - HoldingDelegate

      func viewController(_ viewController: HoldingViewController, didDismissAlert alertController: UIAlertController) {
          resignKeyAndHide()
      }

      // MARK: - Resign

      private func resignKeyAndHide() {
          resignKey()
          isHidden = true
      }

}

All that is left to do is inform AlertPresenter that the alert has been dismissed, again the delegation pattern can be used here:

protocol AlertWindowDelegate: class {
    func alertWindow(_ alertWindow: AlertWindow, didDismissAlert alertController: UIAlertController)
}

class AlertWindow: UIWindow, HoldingDelegate {
    weak var delegate: AlertWindowDelegate?

    //Omitted properties and methods

    func viewController(_ viewController: HoldingViewController, didDismissAlert alertController: UIAlertController) {
        resignKeyAndHide()
        delegate?.alertWindow(self, didDismissAlert: alertController)
    }

    //Omitted methods
}

AlertPresenter then just needs to present the next alert (if present):

class AlertPresenter: AlertWindowDelegate {
    //Omitted properties and methods

    private func showNextAlertIfPresent() {
        guard alertWindow == nil,
            let alertController = alertQueue.dequeue() else {
                return
        }

        let alertWindow = AlertWindow(withAlertController: alertController)
        alertWindow.delegate = self

        alertWindow.present()

        self.alertWindow = alertWindow
    }

    // MARK: - AlertWindowDelegate

    func alertWindow(_ alertWindow: AlertWindow, didDismissAlert alertController: UIAlertController) {
        self.alertWindow = nil
        showNextAlertIfPresent()
    }
}

All queued out 🍾

Congratulations on having made it to the end of an article about queuing. You've shown a level of patience that even many Brits would struggle to achieve.

To recap, in this article we implemented a minimally intrusive alert queuing mechanism that allows us to continue using UIAlertController without having to ask the creators of those alerts to do anything more complicated than calling AlertPresenter.shared.enqueueAlertForPresentation(_:).

To see the above code snippets together in a working example, head over to the repo and clone the project.

What do you think? Let me know by getting in touch on Twitter - @wibosco