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
  1. UIKit won’t queue for you
  2. A first-in, first-out queue
  3. Separating the queue from the alert
  4. Three orders in one day
  5. What isn’t right yet

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:

  1. System settings prompt first, then the in-app one.
  2. Swapped: in-app first, then system.
  3. 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 checks isBeingDismissed and 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 active UIWindowScene.