两个弹窗同时要出来怎么办:用一个队列按顺序 present
UIKit 不会帮你排队弹窗。一个很小的先进先出队列,哪些抽进了 package、哪些留在业务层,一天改了三次提示顺序,以及还没做好的地方。
我之前做过一个需要推送权限的需求,里面涉及两个开关,一个是系统的通知权限,另一个是 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 能一个一个弹,但没法决定它们应该按什么顺序弹。
看提交记录就能发现中间的改动过程:
- 先弹系统权限设置,再弹 App 内部设置。
- 顺序调换过来,先弹 App 内部设置,再弹系统权限。
- 索性不连续弹了。如果两个都没开,直接弹一个合并后的提示,用户点击确认后跳转到 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里面去获取。