两个弹窗同时要出来怎么办:用一个队列按顺序 present

UIKit 不会帮你排队弹窗。一个很小的先进先出队列,哪些抽进了 package、哪些留在业务层,一天改了三次提示顺序,以及还没做好的地方。

文章目录
  1. UIKit 不会帮你排队
  2. 先进先出队列
  3. 把队列和 alert 分开
  4. 一天改了三次顺序
  5. 目前还不够完善的地方

我之前做过一个需要推送权限的需求,里面涉及两个开关,一个是系统的通知权限,另一个是 App 内部的推送设置。如果两个都没开,App 就需要分别弹窗提醒。麻烦的地方在于,要把这两个提示一个接一个地弹出来。

UIKit 不会帮你排队

一个 view controller 同一时间只能 present 一个 view controller。如果屏幕上已经有一个 alert,你在同一个 controller 上再次调用 present,第二个 alert 并不会排队等待,UIKit 直接就不会展示它。

最直接的做法是在第一个 alert 的 dismiss 回调里去 present 第二个。两个 alert 这么写还行,但每多一个 alert 就要多嵌套一层,而且如果 alert 来自不同的地方,它们彼此之间根本不知道对方的存在。

所以我希望能有一个统一的地方接收这些 alert,按顺序一次只弹一个,等前一个彻底关掉之后,再继续弹下一个。

先进先出队列

核心实现大概 40 行代码。

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()
    }
}

为了找到合适的 controller 来做 present,需要顺着最顶层的视图层级一路找下去。

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
    }
}

把队列和 alert 分开

这套代码最初写在业务模块里,是一个 140 行左右的 manager,里面把按钮样式、布局还有队列调度全写在了一起。后来我把它拆成了一个本地 Swift Package,只把排队调度这部分抽了出来。这个 package 完全不需要知道 alert 长什么样,具体的 alert 只要遵守一个 protocol 就行。

public protocol AlertPresentable where Self: UIViewController {
    /// Call this in viewDidDisappear.
    var dismissCallback: (() -> Void)? { get set }
}

对我来说,这次抽取的意义也正在这里。“谁先弹、前一个关没关”从业务逻辑里拿了出去,而“alert 长什么样”依然留在业务层。

对外的公开接口就一个方法调用。

AlertKit.show(alert: alert) {
    alert.resize()
}

这里的 closure 是一个小的扩展点,具体的 alert 可以在这里调整自己的尺寸,而不需要把布局代码重新耦合进 package 里。这个 closure 是在 present 方法被调用之后立刻执行的,而不是等过渡动画结束。我之前在代码注释里写的是“after alert present”,其实容易让人误解。

一天改了三次顺序

队列只能保证 alert 能一个一个弹,但没法决定它们应该按什么顺序弹。

看提交记录就能发现中间的改动过程:

  1. 先弹系统权限设置,再弹 App 内部设置。
  2. 顺序调换过来,先弹 App 内部设置,再弹系统权限。
  3. 索性不连续弹了。如果两个都没开,直接弹一个合并后的提示,用户点击确认后跳转到 App 内部设置页面。

这样一来,最初引出这个需求的场景,在这个流程里已经不再需要连弹两个 alert 了。不过这个队列还是保留了下来,因为其他 alert 还在走它。

合并后的版本里有一个小细节我觉得处理得挺好。打开侧边菜单的操作必须等 alert 完全关闭之后才执行,而且只有在用户点击了确认按钮时才触发,点了取消则不会打开。

var confirmTapped = false
alert.action = { confirmTapped = true }
alert.completeAction = { [weak self] in
    if confirmTapped { self?.openSettingsMenu() }
}

目前还不够完善的地方

  • 页面消失不代表 alert 真正被关掉。 回调放在了 viewDidDisappear 里面,但如果有新的页面盖在 alert 上面,这个方法同样会走。此时 alert 还在,队列却已经开始调度下一个了。如果这个回调被触发了两次,removeFirst() 就会在空数组上执行,导致 crash。更稳妥的做法是判断 isBeingDismissed,并且在回调执行后把它清空。我现在写的话会这样处理,但这版代码里没有。
  • 队列卡住后不会自动重试。 如果当时找不到可以用来 present 的 controller,当前的 alert 就会一直挂起,只能等到下一次有新的 alert 入队时才会尝试恢复。
  • 并不是所有弹窗都接了队列。 同一个页面里有些老弹窗还在走原本直接 present 的逻辑,队列无法保证与这部分弹窗的先后展示顺序。
  • 缺少优先级控制和去重。 优先级更高的紧急 alert 没法插队,同一个 alert 也可能会被重复添加进队列。
  • 依然在使用 keyWindow。 如果应用支持多 scene,这里应该从当前活跃的 UIWindowScene 里面去获取。