When Two Alerts Want the Screen at Once: Presenting Them Through a Queue
UIKit won't queue alerts for you. A small first-in, first-out queue, what moved into a package and what stayed out, three changes of prompt order in one day, and what still isn't right.
On this page
I was working on a feature that needed push permission, which meant two switches: the system’s notification permission, and a push setting inside the app. If both were off, the app had to prompt for both. The trouble was showing two prompts one after the other.
UIKit won’t queue for you
A view controller can present only one view controller at a time. If an alert is on screen and you call present on the same controller again, the second one doesn’t wait its turn. UIKit just won’t show it.
The direct fix is to present the second alert from the first one’s dismissal callback. That works for two, but the nesting grows with each new alert, and alerts from different places don’t know about each other.
So I wanted one place to hand alerts to, which would show one at a time, in order, and move on only after the previous one had closed.
A first-in, first-out queue
The core is about 40 lines:
final class AlertQueue {
struct Item {
let alert: AlertPresentable
let completion: (() -> Void)?
}
private var items: [Item] = []
private var isPresenting = false
@MainActor
func enqueue(_ item: Item) {
items.append(item)
showNextIfPossible()
}
@MainActor
private func showNextIfPossible() {
guard !isPresenting,
let host = UIApplication.shared.keyWindow?.rootViewController?.topMost(),
let next = items.first else { return }
isPresenting = true
host.present(next.alert, animated: true)
next.completion?()
next.alert.dismissCallback = { [weak self] in
self?.alertDidDismiss()
}
}
@MainActor
private func alertDidDismiss() {
items.removeFirst()
isPresenting = false
showNextIfPossible()
}
}
Finding where to present from means walking down through whatever is on top:
extension UIViewController {
func topMost() -> UIViewController {
if let presented = presentedViewController { return presented.topMost() }
if let nav = self as? UINavigationController { return nav.visibleViewController?.topMost() ?? nav }
if let tab = self as? UITabBarController { return tab.selectedViewController?.topMost() ?? tab }
return self
}
}
Separating the queue from the alert
The first version of this code was a manager inside the feature, about 140 lines, handling button styles, layout, and the queue all together. When I pulled it out into a local Swift package, only the scheduling came along. The package doesn’t know what an alert looks like. An alert only has to adopt one protocol:
public protocol AlertPresentable where Self: UIViewController {
/// Call this in viewDidDisappear.
var dismissCallback: (() -> Void)? { get set }
}
To me, that move is what the package is really about. “Who goes first, and has the previous one closed?” left the business code, and “what does this alert look like?” stayed in it.
The public entry point is one call:
AlertKit.show(alert: alert) {
alert.resize()
}
That closure is a small extension point: a specific alert can adjust its size without its layout going back into the package. It runs right after present is called, not when the animation finishes, and my doc comment’s “after alert present” doesn’t make that clear.
Three orders in one day
The queue decides whether alerts can come one at a time. It doesn’t decide what the order should be.
The commit history shows the changes:
- System settings prompt first, then the in-app one.
- Swapped: in-app first, then system.
- Dropped the sequence. When both are off, show one combined prompt, and confirming takes the user to the in-app settings.
So the two-alert case that started all this no longer shows two alerts in that flow. The queue stayed, because other alerts still go through it.
The combined version has a detail I like. Opening the side menu has to wait until the alert has closed, and it should only happen if the user confirmed, not if they cancelled:
var confirmTapped = false
alert.action = { confirmTapped = true }
alert.completeAction = { [weak self] in
if confirmTapped { self?.openSettingsMenu() }
}
What isn’t right yet
- “Disappeared” isn’t always “closed”. The callback fires from
viewDidDisappear, which also runs when something is presented on top of the alert. The queue would then advance while the alert is still there. If it fires a second time,removeFirst()could run on an empty array and crash. A safer version checksisBeingDismissedand clears the callback after using it. That’s what I’d write now, but it’s not what’s in the code. - A stuck queue doesn’t retry. If there’s no controller to present from, the alert waits until something else is enqueued.
- Not everything goes through it. Some alerts in the same screen still use the older direct path, so the queue can’t guarantee ordering against those.
- No priority, no de-duplication. A more urgent alert can’t jump ahead, and the same alert can be queued twice.
- It uses
keyWindow. With several scenes, it should look in the activeUIWindowScene.