從 iOS 8.0 開始加入的 UIAlertController
,大概是大多數人在想要呈現 (present) 警告或者選單時的第一選擇。它的 API 非常的簡單,使用起來就像這樣:
class ViewController: UIViewController {
func deleteSomething() {
// ...
}
func presentDeletionAlert() {
// 創造一個 UIAlertController 的實例。
let alertController = UIAlertController(title: nil, message: "確定要刪除這個東西嗎?", preferredStyle: .alert)
// 加入刪除的動作。
let deleteAction = UIAlertAction(title: "刪除", style: .destructive) { _ in
self.deleteSomething()
}
alertController.addAction(deleteAction)
// 加入取消的動作。
let cancelAction = UIAlertAction(title: "取消", style: .cancel)
alertController.addAction(cancelAction)
// 呈現 alertController。
present(alertController, animated: true)
}
}
我們可以發現,它其實就是一個普通的 UIViewController
子類型,需要用另一個 view controller 去呈現。但是,如果我們想要在 view controller 以外的地方呈現 UIAlertController
的話呢?比方說,當我們在 AppDelegate
裡的 application(\_:open:options:)
,接收到一個無效的 URL 時,我們可能會想要顯示一個警告。然而,AppDelegate
並沒有 present(\_:animated:completion:)
可以用,這時我們可以怎麼去呈現 UIAlertController
呢?
如果 self
不是 view controller 的話,最直覺的解決方法大概是直接找一個 view controller 來用吧!在 AppDelegate
裡,我們可以透過主視窗的 rootViewController
屬性來取得它的根 view controller。也就是說,我們可以這樣做:
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
// ...
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
// 如果沒辦法處理 URL 的話...
if !canHandle(url) {
// 取得主視窗的 rootViewController。
if let rootVC = window?.rootViewController {
// 創造一個 UIAlertController。
let alertController = UIAlertController(title: nil, message: "無效的 URL", preferredStyle: .alert)
let confirmAction = UIAlertAction(title: "知道了", style: .default)
alertController.addAction(confirmAction)
// 用 rootVC 來呈現 alertController。
rootVC.present(alertController, animated: true)
}
return false
}
return true
}
}
然而,這個做法很快就會碰到問題:如果 rootViewController
已經呈現了別的 view controller 的話,alertController
是沒辦法顯示出來的,因為一個 view controller 一次只能呈現一個 view controller。要怎麼解決呢?我們有三種替代方案,一起來看看吧!
方法一:全部 dismiss 掉
呼叫 rootViewController.dismiss(animated: true)
把所有被呈現的 view controller 都去除 (dismiss) 掉,只留下 rootViewController
跟它的子 view controller:
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
// ...
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
if !canHandle(url) {
if let rootVC = window?.rootViewController {
let alertController = UIAlertController(title: nil, message: "無效的 URL", preferredStyle: .alert)
let confirmAction = UIAlertAction(title: "知道了", style: .default)
alertController.addAction(confirmAction)
// 去除所有呈現的 view controller。
rootVC.dismiss(animated: true) {
// 去除完成後,用 rootVC 來呈現 alertController。
rootVC.present(alertController, animated: true)
}
}
return false
}
return true
}
}
這種方法的副作用是會破壞掉整個 app 的呈現階層。如果不想要這樣的話,可以選用方法二或方法三。
方法二:使用 `topViewController`
找到呈現層級最高的 view controller,並用它來呈現警告。後者可以透過一個 extension 來實作:
extension UIWindow {
var topViewController: UIViewController? {
// 用遞迴的方式找到最後被呈現的 view controller。
if var topVC = rootViewController {
while let vc = topVC.presentedViewController {
topVC = vc
}
return topVC
} else {
return nil
}
}
}
接著,再將剛剛的 window?.rootViewController
用 window?.topViewController
取代掉就可以了:
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
// ...
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
if !canHandle(url) {
// 改用 topViewController 來呈現。
if let topVC = window?.topViewController {
let alertController = UIAlertController(title: nil, message: "無效的 URL", preferredStyle: .alert)
let confirmAction = UIAlertAction(title: "知道了", style: .default)
alertController.addAction(confirmAction)
topVC.present(alertController, animated: true)
}
return false
}
return true
}
}
方法三:開新視窗
第三種方法可謂是最厲害的大絕招。我們可以換一種思路,直接開一個新的視窗來專門呈現這一個警告:
import UIKit
// 這可以是一個自由函式(free function),亦即它不需要被放在任何型別裡面。
func presentAlert(_ alertController: UIAlertController) {
// 創造一個 UIWindow 的實例。
let alertWindow = UIWindow()
// UIWindow 預設的背景色是黑色,但我們想要 alertWindow 的背景是透明的。
alertWindow.backgroundColor = nil
// 將 alertWindow 的顯示層級提升到最上方,不讓它被其它視窗擋住。
alertWindow.windowLevel = .alert
// 指派一個空的 UIViewController 給 alertWindow 當 rootViewController。
alertWindow.rootViewController = UIViewController()
// 將 alertWindow 顯示出來。由於我們不需要使 alertWindow 變成主視窗,所以沒有必要用 alertWindow.makeKeyAndVisible()。
alertWindow.isHidden = false
// 使用 alertWindow 的 rootViewController 來呈現警告。
alertWindow.rootViewController?.present(alertController, animated: true)
}
使用這個方法,我們可以完全不管主視窗的 view controller 呈現階層,只要確定警告所在的視窗沒有被主視窗擋住就可以了。實際上的用法如下:
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
// ...
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
if !canHandle(url) {
let alertController = UIAlertController(title: nil, message: "無效的 URL", preferredStyle: .alert)
let confirmAction = UIAlertAction(title: "知道了", style: .default)
alertController.addAction(confirmAction)
// 呈現 alertController。
presentAlert(alertController)
return false
}
return true
}
}
是不是更簡潔了呢?更棒的是,由於 presentAlert(alertController:)
是一個自由函式,它是真的可以被用在任何有 import UIKit
的地方的!舉例來說:
// 實際上並不是個好例子...
extension UIImageView {
func setImage(_ data: Data) {
if let image = UIImage(data: data) {
self.image = image
} else {
// 創造 alertController。
let alertController = UIAlertController(title: nil, message: "無效的 Data", preferredStyle: .alert)
let confirmAction = UIAlertAction(title: "知道了", style: .default)
alertController.addAction(confirmAction)
// 呈現 alertController。
presentAlert(alertController)
}
}
}
當然,我們也可以把 presentAlert(alertController:)
寫成 UIAlertController
的成員,其效果跟自由函式版本是一樣的:
extension UIAlertController {
func present() {
let alertWindow = UIWindow()
alertWindow.backgroundColor = nil
alertWindow.windowLevel = .alert
alertWindow.rootViewController = UIViewController()
alertWindow.isHidden = false
// 改為呈現 self。
alertWindow.rootViewController?.present(self, animated: true)
}
}
看到這裡,你可能會有個疑惑:當使用者按下警告裡的「知道了」等動作,將警告去除掉之後,我們所創造出來的 alertWindow
要怎麼處理呢?答案是:不需要處理!原來,雖然一般的 UIView
在被加入 view 階層之後就會自動被 view 階層保留住 (retain),所以不需要手動保留,但 UIWindow
在顯示出來之後並不會被自動保留。也就是說,如果我們創造 UIWindow
的實例出來後,沒有把它指派到類型的屬性裡面的話,它是會在它被創造的那個方法結束之後自動消滅。
但既然 UIWindow
會自動消滅,那為甚麼我們的 alertWindow
沒有在 present()
方法結束後馬上消失呢?這就要說到 UIWindow
的另一個奇妙特性了:它本身不會被自動保留,除非它的根 view controller 有呈現任何的 view controller。 也就是說,如果它的 rootViewController?.presentedViewController != nil
的話,它就會持續顯示;而一旦 rootViewController?.presentedViewController == nil
,它就會馬上消失掉並且被摧毀。這樣的特性讓我們可以在前面寫的 alertController.present()
裡面省略掉手動保留 alertWindow
的步驟,因為只要 alertController
消失掉,alertWindow
也會跟著被摧毀。
iOS 13 更新用法
在 iOS 13 中,UIWindow
的行為有很大的改變。現在無論它的 `rootViewController` 有沒有呈現其它 view controller,只要我們沒有去持有它,它就可能會直接消失不見。另外,由於現在的 app 架構多了 `UIWindowScene` 這個東西,我們也需要去指定我們的視窗所歸屬的 `windowScene`。
以下是適用於 iOS 13 的示範碼:
import UIKit
class ViewController: UIViewController {
@IBAction func didPressButton(_ sender: UIButton) {
// 建構 alertWindow。
let alertWindow = UIWindow()
if #available(iOS 13.0, *) {
// 取得 view 所屬的 windowScene,並指派給 alertWindow。
guard let windowScene = view.window?.windowScene else { return }
alertWindow.windowScene = windowScene
}
// 設定並顯示 alertWindow。
alertWindow.backgroundColor = nil
alertWindow.windowLevel = .alert
alertWindow.rootViewController = UIViewController()
alertWindow.isHidden = false
// 建立 alertController。
let alertController = UIAlertController(title: "Alert", message: nil, preferredStyle: .alert)
let doneAction = UIAlertAction(title: "Done", style: .default) { action in
// 用 doneAction 的 handler 閉包去持有 alertWindow,創造一個臨時的循環持有。
// 在 alertController 被釋放後,這些閉包也會被釋放,跟著把 alertWindow 給釋放掉。
_ = alertWindow
}
alertController.addAction(doneAction)
// 在 alertWindow 中呈現 alertController。
alertWindow.rootViewController?.present(alertController, animated: true)
}
}
第一個問題,是如何取得一個可以用的 `UIWindowScene`。如果當下可以取得一個正在顯示的 `UIView` 的話,那就可以透過它的 `window` 屬性取得它所屬的視窗,再從這個視窗的 `windowScene` 屬性去取得一個可以用的實體:
let windowScene = view.window?.windowScene
另一個做法是從 `UIApplication` 的單例去找,可參考 StackOverflow 上面的這個回答。
let windowScene = UIApplication.shared.connectedScenes
.first { $0.activationState == .foregroundActive }
第二個問題,是要怎樣去持有 `UIWindow`,以免它被自動釋放掉。持有的方式有很多種,除了寫成某個物件的屬性之外,我們也可以把它丟到一個具備 `@autoclosure` 特性的閉包裡面,讓它被閉包給持有。而在這個例子裡,`UIAlertAction` 的 `handler` 閉包就是一個完美的地方:
let doneAction = UIAlertAction(title: "Done", style: .default) { action in
_ = alertWindow
}
請注意,我們只是將 `alertWindow` 指派給 `_` 而已,甚麼也沒做。如果不在意編譯器的警告的話,甚至可以省略掉 `_ =`:
let doneAction = UIAlertAction(title: "Done", style: .default) { action in
alertWindow // 編譯器會警告。
}
因為光是把 `alertWindow` 放到閉包裡面,就會造成它被持有了。
不過如果你在意可讀性的話,也可以將它寫成這樣:
let doneAction = UIAlertAction(title: "Done", style: .default) { action in
// 隱藏 alertWindow。
alertWindow.isHidden = true
}
這樣的寫法就呼應到前面的 `alertWindow.isHidden = false`,代表「我結束顯示警告後,再隱藏警告視窗」的意思。不過,在這種程式碼本身意義容易混淆的地方,也許還是把註解寫清楚一點更有效吧!
結論
這篇文章介紹了三個從 UIViewController
以外的地方呈現警告的方法 ── 警告其實不限於用 UIAlertController
來做,也可以用任何的 UIViewController
來代替 ── 第一個方法會讓 app 回到最初的根 view controller,而第二個則不會,但這兩個方法都需要去存取主視窗。而第三個方法就不需要存取主視窗,因為我們是直接創造一個新視窗來呈現警告。
不過,最後還是要強調,雖然我們獲得了在任何有 UIKit
的地方都可以開新視窗來顯示警告的能力,但不代表我們就要濫用這個能力。比如之前舉的例子,讓 UIImageView
去顯示「無效的 Data」警告,就是讓一個普通的 view 也獲得了創造視窗的能力,而這很可能違反了 MVC 的分工原則,讓 view 的職責變得太複雜。畢竟,方便所帶來的不一定是簡單,所以越是方便的方法,越是要小心使用。比如說,限制自己只能在 AppDelegate
或 UIViewController
等 controller 物件裡顯示警告,就是個好開始。