UIAlertController 教程:讓你輕鬆在 UIViewController 以外的地方呈現警告
從 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 物件裡顯示警告,就是個好開始。