Finding hope in custom 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.
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
andAlertWindow
to see how things end up.
Thinking about being standard
A standard UIAlertController
alert has 4 parts:
- Foreground view.
- Background view.
- Presentation animation.
- 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:
- Foreground view -
UIAlertController
allows some customisation. - Background view -
UIAlertController
handles this for us. - Presentation animation -
UIAlertController
handles this for us. - 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:
AlertPresenter
is the entry point for presenting and dismissing alerts.AlertWindow
, as we will see shortly, each alert is presented in its ownUIWindow
instance. Used to overcome that tricky navigation issue we spoke about above.HoldingViewController
, the windows root view-controller that is responsible for presenting theAlertContainerViewController
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 theAlertContainerViewController
instance with the same animation as a standardUIAlertController
.CustomAlertDismissAnimationController
is responsible for dismissing theAlertContainerViewController
instance with the same animation as a standardUIAlertController
.
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:
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:
.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 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:
- Set the alert as the window's
rootViewController
. - 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. 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 AlertWindow
to 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:
- 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).
- 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 forHoldingViewController
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:
- Sizing - the alert occupies the full-screen.
- 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:
- Store the alert passed in via the init'er as a property.
- Embed the alert as a child view-controller.
- 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:
- Create an
AlertContainerViewController
instance. - 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:
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 atransitioningDelegate
which has a type ofUIViewControllerTransitioningDelegate
. When a transaction is about to happen, iOS asks the transitioning-delegate for an animator to use. If thetransitioningDelegate
is nil or the necessaryUIViewControllerTransitioningDelegate
method hasn't been implemented, then iOS will fall back to using the default animation for that type of transition.UIViewControllerAnimatedTransitioning
- is a set of methods for implementing the animations for a custom view controller transition. A class that conforms toUIViewControllerAnimatedTransitioning
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:
CustomAlertPresentAnimationController
conforms toUIViewControllerAnimatedTransitioning
and needs to be a subclass ofNSObject
asUIViewControllerAnimatedTransitioning
extendsNSObjectProtocol
.- In
transitionDuration(using:)
the duration of the animation is specified as0.2
seconds - we've copied the timing of anUIAlertController
alert here. UIAlertController
instances when animated onto the screen, gradually fade in with a slight bounce effect before settling to its actual size. InanimateTransition(using:)
we use theUIViewControllerContextTransitioning
instance (transitionContext
) to get the offscreen view of the to-view-controller (i.e. theAlertContainerViewController
instance holding our alert). We take a snapshot (screenshot) of theAlertContainerViewController
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 aUIAlertController
animation. Taking a snapshot means we avoid having to deal with any constraint issues that may arise from manipulating the actualAlertContainerViewController
instances view. Once the animation is finished, we remove the snapshot from the view hierarchy and reveal theAlertContainerViewController
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:
- Set the
modalPresentationStyle
tocustom
as we will be providing the modal presentation animation. - Set this
HoldingViewController
instance as thetransitioningDelegate
of theAlertContainerViewController
instance. - Return an instance of
CustomAlertPresentAnimationController
when presenting anAlertContainerViewController
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:
- Find the window that is showing the alert.
- Call
dismiss(completion:)
on that window to begin the dismissal process. - 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:
- Added a new
viewController
property that gets the alert being displayed and returns it. - Pass the dismissal command onto the
HoldingViewController
instance. - 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 asHoldingViewController
andAlertContainerViewController
are private implementation details ofAlertWindow
.
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