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?.rootViewControllerwindow?.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 特性的閉包裡面,讓它被閉包給持有。而在這個例子裡,UIAlertActionhandler 閉包就是一個完美的地方:

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 的職責變得太複雜。畢竟,方便所帶來的不一定是簡單,所以越是方便的方法,越是要小心使用。比如說,限制自己只能在 AppDelegateUIViewController 等 controller 物件裡顯示警告,就是個好開始。


iOS 開發者、寫作者、filmmaker。現正負責開發 Storyboards by narrativesaw 此一故事板文件 app 中。深深認同 Swift 對於程式碼易讀性的重視。個人網站:lihenghsu.com。電郵:[email protected]

blog comments powered by Disqus
Shares
Share This