Alert queuing with windows
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 😔.
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:
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:
.normal
.statusBar
.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 forHoldingViewController
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:
- My first attempt involved subclassing
UIAlertController
and overridingviewDidDisappear(_:)
with a call toAlertPresenter
- everyone would then use this subclass instead ofUIAlertController
directly. However, this approach is explicitly warned against in the documentation forUIAlertController
:
👎
-
My second attempt involved requiring each developer to explicitly call
AlertPresenter
from inside eachUIAlertAction
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 👎. -
My third attempt involved using a view model to abstract the creation of
UIAlertController
instances to a factory method where a call toAlertPresenter
could then be injected into eachUIAlertAction
closure (during the conversion from view model toUIAlertController
instance). However, this approach would require a lot of custom code to be written, maintained and tested - usingUIAlertController
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 ofviewDidAppear(_:)
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 useUIAlertController
without having to care about the queue outweighed my discomfort. If Apple changes this to better match the behaviour of otherUIViewController
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.