Finding hope in custom alerts

May 26th, 2020
#ui #alerts

UIAlertController alerts form the backbone of a lot of the interactions between our users and our apps. Alerts are often shown at those critical points in our apps where we are asking to confirm an action or allow access to a resource. While there have been some changes to alerts over the years, very little has changed about their appearance. This lack of customisation presents significant difficulties for app designers 😱. Having an UIAlertController alert pop up at the most critical points of our apps with its semi-transparent rectangular layout and standard fonts, breaking the app's theme is enough to make any app designer spill their flat white coffee down their checkered shirt and throw their Caran d'Ache coloured pencils across the table with an angst that few could ever comprehend, never mind experience. At this point, as you gaze into their tear-filled eyes, you offer a few comforting words:

"We can always write our own custom alerts instead"

Slowly as your words start to cut through their anguish, they start to nod, and with strength returning to their voice, they say:

"Sounds good man, I'll get started on those right away"

And with that, you turn away and start thinking about how you can present those custom alerts.

A photo of a designer sitting, drinking a flat white and thinking about beard oil

This article will look at how to build a custom alert presenter that will try and follow most of the conventions used by UIAlertController alerts. Along the way, we will even overcome a particularly tricky and challenging to reproduce navigation bug πŸ’ͺ.

This post will gradually build up to a working example however if you are too excited and want to jump ahead then head on over to the completed example and take a look at AlertPresenter and AlertWindow to see how things end up.

Thinking about being standard

A standard UIAlertController alert has 4 parts:

  1. Foreground view.
  2. Background view.
  3. Presentation animation.
  4. Dismissal animation.

The first thing to decide is:

"What is part of the custom alert, and what is part of the mechanism to show an alert?"

Let's look at how UIAlertController handles the 4 parts of an alert:

  1. Foreground view - UIAlertController allows some customisation.
  2. Background view - UIAlertController handles this for us.
  3. Presentation animation - UIAlertController handles this for us.
  4. Dismissal animation - UIAlertController handles this for us.

The only customisable part of an UIAlertController alert is the foreground view. This lack of control with the other parts may at first feel limiting, but by preventing customisation of 3-of-the-4 parts, iOS forces us to focus the most critical part - the message. The message is contained in the foreground view.

Just like UIAlertController, the alert presentation layer we will build below will only allow the foreground view will be customisable. This limitation will ensure that presenting and dismissing alerts will happen consistently across the app. Instead of the foreground view being a UIView instance, it will be a UIViewController instance to provide greater control to our custom alerts. This UIViewController instance will be added to the view hierarchy as a child view-controller. This functionality will come together in the following class structure:

Class diagram showing how custom alerts are presented

  • AlertPresenter is the entry point for presenting and dismissing alerts.
  • AlertWindow, as we will see shortly, each alert is presented in its own UIWindow instance. Used to overcome that tricky navigation issue we spoke about above.
  • HoldingViewController, the windows root view-controller that is responsible for presenting the AlertContainerViewController that will hold the alert view-controller as a child view-controller.
  • AlertContainerViewController, the parent view-controller that the alert view-controller is embedded in as a child view-controller.
  • CustomAlertPresentAnimationController is responsible for presenting the AlertContainerViewController instance with the same animation as a standard UIAlertController.
  • CustomAlertDismissAnimationController is responsible for dismissing the AlertContainerViewController instance with the same animation as a standard UIAlertController.

HoldingViewController, AlertContainerViewController, CustomAlertPresentAnimationController and CustomAlertDismissAnimationController are private and only known to AlertWindow.

Don't worry if that doesn't all make sense yet, we will look into each class in greater depth below.

Let's start with AlertPresenter:

class AlertPresenter {
    static let shared = AlertPresenter()

    // MARK: - Present

    func presentAlert(_ viewController: UIViewController) {
        os_log(.info, "Alert being presented")

        //TODO: Present
    }
}

AlertPresenter is a singleton, so the same instance will be used to present (and dismiss) all alerts. As AlertPresenter isn't a UIViewController subclass, it's not possible to directly present the alert. Instead, we are going to use a dedicated UIWindow instance to present alerts from. Using a dedicated UIWindow instance should avoid the situation where multiple simultaneous navigation events (presentation/dismissal) occur at the same time, resulting in one of those events being cancelled and the following error is generated:

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

The navigation stack in one window is independent of the navigation stack of any other windows. An alert in the process of being presented on window A will not cause a navigation collision with a view-controller being pushed on window B πŸ₯³.

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 and 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 a dedicated window

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

class AlertWindow: UIWindow {

    // MARK: - Init

    init(withViewController viewController: UIViewController) {
        super.init(frame: UIScreen.main.bounds)

        // 1
        rootViewController = viewController

        // 2
        windowLevel = .alert
    }

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

    // MARK: - Present

    // 3
    func present() {
        makeKeyAndVisible()
    }
}

In AlertWindow we:

  1. Set the alert as the window's rootViewController.
  2. Set the window level value to .alert. The .alert level will put this window above the app's main window, ensuring that it can be seen.
  3. present() is a bit of syntactic sugar around making the window key and visible, it will act as a mirror to the soon-to-be-seen dismiss method.

An AlertWindow instance is only meant to show one alert and then be disposed of.

Let's use AlertWindowto present alerts:

class AlertPresenter {
    // 1
    private var alertWindows = Set()

    //Omitted other properties

    // MARK: - Present

    // 2
    func presentAlert(_ viewController: UIViewController) {
        os_log(.info, "Alert being presented")

        let alertWindow = AlertWindow(withViewController: viewController)
        alertWindow.present()

        alertWindows.insert(alertWindow)
    }
}

With the above changes we:

  1. Store all windows that are being presented in a Set. Storing the window inside the set will keep that window alive by increasing its retain count (as making a window key-and-visible doesn't increase the retain count).
  2. Create an AlertWindow instance using the alert to be presented and then instruct that window to present itself.

If you were to create an instance of AlertWindow and make it visible, you would notice that the alert is presented without an animation - it just appears. A window's root view-controller cannot be animated on-screen so an intermediate view-controller is needed which can be the window's root view-controller and then the alert can be presented from that view-controller:

class HoldingViewController: UIViewController {
    private let viewController: UIViewController

    // MARK: - Init

    init(withViewController viewController: UIViewController) {
        self.viewController = viewController
        super.init(nibName: nil, bundle: nil)
    }

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

    // MARK: - ViewLifecycle

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

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

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

Trying to present an alert earlier will cause a navigation collision 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 go back to AlertWindow and make use of HoldingViewController:

class AlertWindow: UIWindow {
    private let holdingViewController: HoldingViewController

    // MARK: - Init

    init(withViewController viewController: UIViewController) {
        holdingViewController = HoldingViewController(withViewController: viewController)
        super.init(frame: UIScreen.main.bounds)

        rootViewController = holdingViewController

        windowLevel = .alert
    }


    // MARK: - Present

    func present() {
        makeKeyAndVisible()
    }
}

If you run the project with the above changes and hook in your own alert, you would notice two issues:

  1. Sizing - the alert occupies the full-screen.
  2. Presentation - the alert is animated from bottom to top.

Sizing alerts

An alert should be shown at the smallest size possible - we can't do this if that alert is the modal. Instead, we need to embed that alert into another view controller. HoldingViewController can't be used for this as it is being used as the window's root view-controller so can't be animated on-screen. We need to introduce a new view-controller that will act as a container so that that container can be animated on-screen from the HoldingViewController instance:

class AlertContainerViewController: UIViewController {
    let childViewController: UIViewController

    // MARK: - Init

    // 1
    init(withChildViewController childViewController: UIViewController) {
        self.childViewController = childViewController
        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()

        // 2
        addChild(childViewController)
        view.addSubview(childViewController.view)
        childViewController.didMove(toParent: self)

        // 3
        childViewController.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            childViewController.view.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            childViewController.view.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            childViewController.view.widthAnchor.constraint(lessThanOrEqualTo: view.widthAnchor, multiplier: 1),
            childViewController.view.heightAnchor.constraint(lessThanOrEqualTo: view.heightAnchor, multiplier: 1)
        ])
    }
}

In AlertContainerViewController we:

  1. Store the alert passed in via the init'er as a property.
  2. Embed the alert as a child view-controller.
  3. Centre the alert and constrain it to (at maximum) the width of the view's width and height.

I don't view an alert that is too large for the container as being the presentation layers problem to solve - if an alert is too large for a single screen, I'd question if it really is an alert.

Let's update HoldingViewController to use AlertContainerViewController:

class HoldingViewController: UIViewController {
    let containerViewController: AlertContainerViewController

    // MARK: - Init

    init(withViewController viewController: UIViewController) {
        // 1
        containerViewController = AlertContainerViewController(withChildViewController: UIViewController)
        super.init(nibName: nil, bundle: nil)
    }

    //Omitted other methods

    // MARK: - ViewLifecycle

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

        // 2
        present(containerViewController, animated: true, completion: nil)
    }
}

With the changes to HoldingViewController we:

  1. Create an AlertContainerViewController instance.
  2. Modally present that AlertContainerViewController instance rather than the alert.

Now if you run the above code with your UIViewController subclass, you would notice that your alert is compressed to be as small as it can be and centred in the screen.

Time to address the second point - Presentation.

Presenting alerts

Let's change the presentation animation to make it feel more like the UIAlertController.

As we move from one view-controller to another, we are used to seeing different types of animation, the most common being:

  • Push: the new view slides in from the side on top of the current view.
  • Pop: the current view slides out to the side to reveal another view underneath.
  • Present: the new view slides up from the bottom on top of the current view.
  • Dismiss: the current view slides down to reveal another vie underneath.

These view transactions are provided free by iOS. However it is possible to specify our own view transactions by using the Transitions API. The Transitions API is a suite of protocols that determine how custom transitions should behave, there are quite a few protocols in the suite however only two of them are of interest to us:

  1. UIViewControllerTransitioningDelegate - is a set of methods that vend objects used to manage a fixed-length or interactive transition between view controllers. Every view-controller has a transitioningDelegate which has a type of UIViewControllerTransitioningDelegate. When a transaction is about to happen, iOS asks the transitioning-delegate for an animator to use. If the transitioningDelegate is nil or the necessary UIViewControllerTransitioningDelegate method hasn't been implemented, then iOS will fall back to using the default animation for that type of transition.
  2. UIViewControllerAnimatedTransitioning - is a set of methods for implementing the animations for a custom view controller transition. A class that conforms to UIViewControllerAnimatedTransitioning is known as the animator. An animator controls the duration of the transition and allows for manipulating the from-view-controller's view and to-view-controller's view on the transition canvas.

I briefly covered the Transitions API above - if you want to read more, I'd recommend this post.

Let's create an animator to handle presenting the alert:

class CustomAlertPresentAnimationController: NSObject, UIViewControllerAnimatedTransitioning // 1 {

    // MARK: - UIViewControllerAnimatedTransitioning

    // 2
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.2
    }

    // 3
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        guard let toViewController = transitionContext.viewController(forKey: .to),
            let snapshot = toViewController.view.snapshotView(afterScreenUpdates: true)
            else {
                return
        }

        let containerView = transitionContext.containerView
        let finalFrame = transitionContext.finalFrame(for: toViewController)

        snapshot.frame = finalFrame
        snapshot.transform = CGAffineTransform(scaleX: 1.1, y: 1.1)
        snapshot.alpha = 0.0

        containerView.addSubview(toViewController.view)
        containerView.addSubview(snapshot)
        toViewController.view.isHidden = true

        let duration = transitionDuration(using: transitionContext)

        UIView.animate(withDuration: duration, animations: {
            snapshot.alpha = 1.0
            snapshot.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
        }) { _ in
            toViewController.view.isHidden = false
            snapshot.removeFromSuperview()
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }
    }
}

CustomAlertPresentAnimationController can at first glance look a little intimidating so let's break it down:

  1. CustomAlertPresentAnimationController conforms to UIViewControllerAnimatedTransitioning and needs to be a subclass of NSObject as UIViewControllerAnimatedTransitioning extends NSObjectProtocol.
  2. In transitionDuration(using:) the duration of the animation is specified as 0.2 seconds - we've copied the timing of an UIAlertController alert here.
  3. UIAlertController instances when animated onto the screen, gradually fade in with a slight bounce effect before settling to its actual size. In animateTransition(using:) we use the UIViewControllerContextTransitioning instance (transitionContext) to get the offscreen view of the to-view-controller (i.e. the AlertContainerViewController instance holding our alert). We take a snapshot (screenshot) of the AlertContainerViewController instances view, add that snapshot to the animator's container view (think of this as a temporary transaction view that is present during the animation) and animate the snapshot on the screen to mimic a UIAlertController animation. Taking a snapshot means we avoid having to deal with any constraint issues that may arise from manipulating the actual AlertContainerViewController instances view. Once the animation is finished, we remove the snapshot from the view hierarchy and reveal the AlertContainerViewController instance's view occupying the same position.

As HoldingViewController is the view-controller that presents the AlertContainerViewController instance, it needs to conform to UIViewControllerTransitioningDelegate:

class HoldingViewController: UIViewController, UIViewControllerTransitioningDelegate {
    //Omitted properties

    init(withViewController viewController: UIViewController) {
        //Omitted start of method

        // 1
        containerViewController.modalPresentationStyle = .custom

        // 2
        containerViewController.transitioningDelegate = self
    }

    //Omitted other methods

    // MARK: - UIViewControllerTransitioningDelegate

    // 3
    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return CustomAlertPresentAnimationController()
    }
}

With the changes to HoldingViewController we:

  1. Set the modalPresentationStyle to custom as we will be providing the modal presentation animation.
  2. Set this HoldingViewController instance as the transitioningDelegate of the AlertContainerViewController instance.
  3. Return an instance of CustomAlertPresentAnimationController when presenting an AlertContainerViewController instance.

If you add in the above code changes to your project and run it, you would now see your alert being animated onto the screen in the same way as an UIAlertController alert.

So we just need to add in a semi-transparent background, and our alert presentation will be complete:

class AlertContainerViewController: UIViewController {
    //Omitted properties and other methods

    override func viewDidLoad() {
        super.viewDidLoad()

        let backgroundView = UIView()
        backgroundView.backgroundColor = UIColor.darkGray.withAlphaComponent(0.75)
        backgroundView.translatesAutoresizingMaskIntoConstraints = false

        view.addSubview(backgroundView)

        //Omitted child view-controller setup

        NSLayoutConstraint.activate([
            //Omitted child view-controller layout setup

            backgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            backgroundView.topAnchor.constraint(equalTo: view.topAnchor),
            backgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            backgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }
}

Now our alert presentation is complete; let's turn our attention to dismissing that alert.

Dismissing alerts

Just like presentation, dismissal will happen inside the AlertPresenter:

class AlertPresenter {
    //Omitted properties and other methods

    func dismissAlert(_ viewController: UIViewController) {
        // 1
        guard let alertWindow = alertWindows.first(where: { $0.viewController == viewController } )  else {
            return
        }

        os_log(.info, "Alert being dismissed")

        // 2
        alertWindow.dismiss { [weak self] in
            // 3
            self?.alertWindows.remove(alertWindow)
        }
    }
}

With the changes to AlertPresenter we:

  1. Find the window that is showing the alert.
  2. Call dismiss(completion:) on that window to begin the dismissal process.
  3. Remove the window from alertWindows once the dismissal has completed.

There are a few ways we could have implemented the dismissal logic. In the end, I decided to require all custom alerts to call dismissAlert(_:) when they are ready to be dismissed. This direct calling approach has symmetry with how the alert is presented and is very simple.

If you try to run the above method you will get an error because AlertWindow doesn't yet have an viewController property or dismissAlert(_:) method so let's add them in:

class AlertWindow: UIWindow {
    // 1
    var viewController: UIViewController {
        return holdingViewController.containerViewController.childViewController
    }

    //Omitted other methods and properties

    // MARK: - Dismiss

    func dismiss(completion: @escaping (() -> Void)) {
        // 2
        holdingViewController.dismissAlert { [weak self] in
            // 3
            self?.resignKeyAndHide()
            completion()
        }
    }

    // MARK: - Resign

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

With the above changes to AlertWindow we:

  1. Added a new viewController property that gets the alert being displayed and returns it.
  2. Pass the dismissal command onto the HoldingViewController instance.
  3. Resign the window and hide it once the dismissal has completed.

Normally I don't like the level of method chaining shown inside the viewController property. However, I feel comfortable doing it here as HoldingViewController and AlertContainerViewController are private implementation details of AlertWindow.

Let's add a dismissAlert(_:) method into HoldingViewController:

class HoldingViewController: UIViewController, UIViewControllerTransitioningDelegate {
    //Omitted properties and other methods

    // MARK: - Dismiss

    func dismissAlert(completion: @escaping (() -> Void)) {
        containerViewController.dismiss(animated: true, completion: {
            completion()
        })
    }
}

With the above change, we call the standard UIKit dismiss(animation:completion:) on the containerViewController and trigger the completion closure when that dismiss action completes.

If you add in the above code changes to your project and run it by calling AlertPresenter.shared.dismiss(completion:) when your alert's dismissal button is pressed then your alert should be dismissed. However, it will still be using the standard modal dismissal animation. Just like with presenting, dismissing will need an animator:

class CustomAlertDismissAnimationController: NSObject, UIViewControllerAnimatedTransitioning {

    // MARK: - UIViewControllerAnimatedTransitioning

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.2
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        guard let fromViewController = transitionContext.viewController(forKey: .from),
            let snapshot = fromViewController.view.snapshotView(afterScreenUpdates: true)
            else {
                return
        }

        let containerView = transitionContext.containerView
        let finalFrame = transitionContext.finalFrame(for: fromViewController)

        snapshot.frame = finalFrame

        containerView.addSubview(snapshot)
        fromViewController.view.isHidden = true

        let duration = transitionDuration(using: transitionContext)

        UIView.animate(withDuration: duration, animations: {
            snapshot.alpha = 0.0
        }) { _ in
            snapshot.removeFromSuperview()
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }
    }
}

CustomAlertDismissAnimationController is mostly the opposite of CustomAlertPresentAnimationController but without the bounce effect.

Now HoldingViewController just has to return an CustomAlertDismissAnimationController instance at the appropriate moment:

class HoldingViewController: UIViewController, UIViewControllerTransitioningDelegate {
    //Omitted properties and other methods

    func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return CustomAlertDismissAnimationController()
    }
}

And that completes our custom alert presenter.

πŸΎπŸ’ƒπŸ»πŸ•ΊπŸ»

Feeling hopeful? 🌞

To recap, we built a simple custom alert presentation system that takes an alert view-controller, places it in the centre of a semi-transparent container and presents it in a dedicated window, all the while doing so with the same feel as presenting a UIAlertController instance.

I spent quite a long time toying with the idea of having AlertPresenter accept view-models rather than view-controllers (with the view-model then being transformed into view-controllers inside the presenter). However, the view-model solution always ended up feeling very confused - was AlertPresenter part of the app's infrastructure or part of the app's UI. By using view-models AlertPresenter had to be both 😧. Each time someone created a new type of alert, AlertPresenter would need to be modified to know about the new view-model - breaking the open/closed principle and opening the door to unexpected consequences rippling through the app (you can see this approach on this branch) via the AlertPresenter. By moving the alert view-controller creation from AlertPresenter to the component that wanted to use AlertPresenter, I was able to give AlertPresenter a clear purpose: AlertPresenter takes a view-controller and presents it in the same manner as an UIAlertController alert. This clear division of responsibility between alert creator and alert presenter, I believe, has meant that AlertPresenter is a simple, easy-to-understand component that should very rarely need to be modified.

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