iOS App 程式開發

你也可以自訂搖動還原 (Shake to Undo) 的功能?一起來拆解並實作吧!

你也可以自訂搖動還原 (Shake to Undo) 的功能?一起來拆解並實作吧!
你也可以自訂搖動還原 (Shake to Undo) 的功能?一起來拆解並實作吧!
In: iOS App 程式開發, Swift 程式語言, UI, UIKit, Xcode

在 iOS 上編輯內容的時候,如果要還原或重做步驟的話,通常可以透過搖動來呼叫出一個還原的警告:

搖動還原-1

這個搖動還原 (Shake to Undo) 功能在 UITextView 或者 UITextField 等文字編輯的 view 上是內建的,但大多數其他的 view 都沒有預設實作。還好,UIKit 其實已經幫我們做好了從動作偵測到顯示警告的部分,我們只需要提供第一響應者 (First Responder),並使用它的 undoManager 就可以了。

編者按:由於這篇文章是前兩篇的延伸教程,所以在看這篇文章之前,請先參考 UIAlertController 教程First Responder 教程

使用系統提供的搖動還原

第一步,就是去啟用 UIApplication 單例的 applicationSupportsShakeToEdit 功能:

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {

        // 這一行就啟用了動作偵測加上顯示警告的部分。
        application.applicationSupportsShakeToEdit = true
    }

}

接著,找到想要被執行還原/重做的響應者,也就是 UIResponder 物件,並使用它的 undoManager 來做還原管理:

class ViewController: UIViewController {

    // ...

    // 給 self 一個專屬的 UndoManager,否則它會使用 window 的 undoManager。
    private let _undoManager = UndoManager()

    override var undoManager: UndoManager! {
        return _undoManager
    }

    @IBOutlet var imageView: UIImageView!

    @IBAction func deleteButtonDidPress(_ sender: UIButton) {
        setImage(nil)
    }

    // 實作了還原管理的編輯動作。
    func setImage(_ image: UIImage?) {

        // 抽取舊的 image。
        let oldImage = imageView.image

        // 向 undoManager 註冊還原動作。
        undoManager.registerUndo(self) {

            // 將舊 image 丟回給同一個方法。
            $0.setImage(oldImage)
        }

        // 設定新 image。
        imageView.image = image
    }

}

第三個步驟是,我們必須要將該響應者設成第一響應者:

class ViewController: UIViewController {

    // ...

    // UIViewController 預設是不能成為第一響應者的,所以我們必須複寫這個屬性。
    override var canBecomeFirstResponder: Bool {
        return true
    }

    // 設定只要 view 有顯示,self 就是第一響應者。
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        becomeFirstResponder()
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        resignFirstResponder()
    }

}

如此一來,就可以在 ViewController 的場景使用搖動還原的功能了。

破解運作原理

要實作系統提供的搖動還原功能就是這麼簡單。然而,你有沒有曾經好奇過,為什麼一定要使 ViewController 成為第一響應者,搖動還原才會有作用呢?答案可能跟你想的不一樣。

成為第一響應者的作用,是讓 ViewController 可以最早接收動作事件。可是在搖動還原的過程中,誰第一個接收到搖動的事件卻不重要。這件事可以用這個方法來證明:

class ViewController: UIViewController {

    // ...

    // 即使完全不呼叫 super.motionEnded(_:with:) 而只是把動作事件往上傳,搖手機的時候還原警告還是會跳出來。
    override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
        next?.motionEnded(motion, with: event)
    }

}

事實上,直接把動作事件傳給響應鏈的最尾端 ── UIApplication 也是可以的:

class ViewController: UIViewController {

    // ...

    override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
        // 改成這樣之後搖動還原仍然可以作用,但拿掉這一行之後就沒辦法了。
        UIApplication.shared.motionEnded(motion, with: event)
    }

}

由此可以確認,搖動還原的動作事件處理其實是 UIApplication 在負責的。這延伸出兩個問題,第一:為什麼不是第一響應者,也就是 ViewController 自己處理呢?第二,那又為什麼要讓 ViewController 成為第一響應者呢?

第一個問題是跟這個功能背後的實作設計有關,但很可惜,我並不是 UIKit 團隊的一員,所以沒辦法回答說為什麼要設計成由 UIApplication 來處理搖動還原,而不是直接做進 UIResponder 裡面。合理的猜測是,搖動還原包含了顯示警告的動作,而這個動作超出了 UIResponder 的責任範圍。比如說,UIView 就不應該有呈現警告的能力。相比起來,由 UIApplication 來處理更合乎它們的職責分配。

第二個問題的答案則是已經藏在前面的實作步驟裡了 ── ViewController 必須成為第一響應者才能啟用搖動還原功能,是因為這樣子 UIApplication 才知道要用它的 undoManager 來還原/重做。

UIResponder 的設計其實很有趣,因為它不僅僅是一個響應者,更是一個編輯者。舉例來說,它擁有一個 undoManager 屬性,並且遵守了 UIResponderStandardEditActions 這個定義編輯動作的協定 (protocol)。再者,becomeFirstResponder()resignFirstResponder() 這兩個方法在 UITextViewUITextField 上面,其實就是進入與退出編輯模式的意思。

從這點來說,第一響應者雖然表面上只是第一個接收動作與鍵盤等輸入的物件,但它其實更常代表的是現在正在被編輯的物件。比如說,除了剛剛提到的文字編輯物件之外,當我們想要針對某個響應者物件去顯示編輯選單的時候,我們也是要先使該物件成為第一響應者,好讓 UIMenuController 知道它正在編輯的對象是誰,要把編輯動作傳送給哪個響應者。同樣的概念也可以被套用在搖動還原上 ── 我們使要被還原/重做的響應者成為第一響應者,代表我們向 UIApplication 宣告說這個響應者正在被編輯,要它針對該第一響應者去顯示還原警告。

現在,我們已經大致知道 UIKit 內建的搖動還原,是由 UIApplication 在接收到搖動動作之後,去針對第一響應者的 undoManager 顯示一個警告。畫成圖的話就是這樣:

搖動還原-2

平常的時候,我們並不用知道這圖中的各個步驟要怎麼做到,因為 UIApplicationapplicationSupportsShakeToEdit 已經都幫你實作好了。然而,它使用起來雖簡單,它的 UI 卻幾乎沒有自訂的空間,因為我們完全無法取得它所呈現的 UIAlertController 物件。不只是沒有公開 API 而已,是連該警告物件所在的視窗都沒有在 UIApplication.shared.windows 裡面。

讓我們執行一個簡單的檢查。用 Xcode 啟動模擬器或實機上的 app,並呼叫出搖動還原的警告之後,執行 Xcode 裡的 Debug View Hierarchy 功能:

搖動還原-3

沒錯,該警告根本沒有在整個 app 的 view 階層裡面。也就是說,如果想要自訂這個還原警告的界面,哪怕只是改變它按鈕的顏色,我們都得要自己去重新實作整個機制才行。

那麼,該怎麼做呢?

手動實作搖動還原

在開始之前,我們要先把系統內建的實作關掉,以免搖一下就使內建跟自製的警告一起跳出來:

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {

        // 關掉搖動還原功能的內建版本。
        application.applicationSupportsShakeToEdit = false
    }

}

接著要做的,是定義方法的介面。假設我們已經按照這篇文章這篇文章定義了這些方法:

extension UIApplication {

    // 可以取得主視窗的第一響應者。
    var firstResponder: UIResponder? { get }

}

extension UIAlertController {

    // 可以自己呈現自己,不需要既有的 VC。
    func present()

}

那我們就只需要再多定義一個介面,如下:

extension UIAlertController {

    convenience init?(undoManager: UndoManager)

}

由這個 convenience init,我們可以很方便地產生還原警告。至於實作的話,不外乎用 undoManager 的各種屬性來產生 UIAlertAction,並決定警告的標題:

extension UIAlertController {

    // 此為內建版本的還原警告模仿版。
    convenience public init?(undoManager: UndoManager) {
        self.init(title: nil, message: nil, preferredStyle: .alert)

        // 定義動作。
        let undoAction = UIAlertAction(title: "Undo", style: .default) { action in
            undoManager.undo()
        }
        let redoAction = UIAlertAction(title: undoManager.canUndo ? undoManager.redoMenuItemTitle : "Redo", style: .default) { action in
            undoManager.redo()
        }
        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel)

        // 依能不能還原與重做來決定該加入哪些動作。
        switch (undoManager.canUndo, undoManager.canRedo) {
        case (true, false):
            title = undoManager.undoMenuItemTitle
            addAction(cancelAction)
            addAction(undoAction)

        case (false, true):
            title = undoManager.redoMenuItemTitle
            addAction(cancelAction)
            addAction(redoAction)

        case (true, true):
            title = undoManager.undoMenuItemTitle
            addAction(undoAction)
            addAction(redoAction)
            addAction(cancelAction)

        // 如果不能還原也不能重做的話,就不應該顯示還原警告。
        case (false, false):
            return nil
        }
    }

}

準備好以上這些方法之後,接著就是想辦法攔截搖動的動作事件了。也就是說,我們必須要找到某個響應鏈上的響應者,並複寫它的 motionEnded(_:with:) 方法。

在響應鏈中,有四個物件是通常都會被經過的:UIApplication.shared、主視窗、主視窗的根 View Controller、以及第一響應者本身。在這四個地方攔截搖動事件都可以,但越前面者就越一勞永逸。接下來,就讓我們一一來看看要如何實作事件攔截。

用第一響應者攔截

我們前面例子的第一響應者是 ViewController。如果是在這裡就攔截的話,就必須在所有需要搖動還原的響應者物件上都實作,但優點是實作方便。這個方法比較適合確定只會有一個地方需要搖動還原的狀況。

// 也適用於任何 UIResponder 的 subclass。
class ViewController: UIViewController {

    // ...

    override func motionEnded(_ motion: UIEvent.EventSubtype, 
            with event: UIEvent?) {
        super.motionEnded(motion, with: event)

        if motion == .motionShake {

            // 因為前面我們已經把 undoManager 的型別從 UndoManager? 複寫為 UndoManager!,所以這裡不需要將它用 if let 取出。
            // 另外,如果 undoManager 裡沒有任何還原或重做的動作的話,這個建構式會直接回傳 nil,也就不會出現警告。
            UIAlertController(undoManager: undoManager)?.present()
        }
    }

}

我們可以看到,除了複寫所需的模板碼 (boilerplate code) 外,我們只用了三行就完成實作了,而且還不需要再去問第一響應者是誰。

用根 View Controller 攔截

這個方式也相當方便,因為只要在一個地方實作就可以支援整個 app 的搖動還原。

// 或者你原本的根 VC(通常是 storyboard 上面的 initial view controller)。
class RootViewController: UIViewController {

    // ...

    override func motionEnded(_ motion: UIEvent.EventSubtype, 
            with event: UIEvent?) {
        super.motionEnded(motion, with: event)

        if motion == .motionShake {

            // 需要特別去跟 UIApplication 要第一響應者,以取得它的 undoManager。
            if let undoManager = UIApplication.shared.firstResponder?.undoManager {
                UIAlertController(undoManager: undoManager)?.present()
            }
        }
    }

}

然而,根 VC 的職責通常已經很重,再加上這段程式碼很可能會讓它更為龐大、紊亂。所以,在職責相對輕的 UIWindowUIApplication 實作可能是更好的選擇。唯一的問題是,要攔截動作事件就必須要寫 subclass,但 UIWindowUIApplication 可以被 subclass 嗎?我們又要怎麼讓 UIKit 去使用我們寫的 subclass 呢?

用主視窗攔截

即使官方文件裡說它們通常不會被 subclass,但這兩個 class 還是可以被 subclass 的,只是需要再特別告知 UIKit 去用我們寫的 subclass 而已。這裡我們先來建立 UIWindow 的 subclass Window

class Window: UIWindow {

    // 跟用根 VC 攔截的程式碼完全相同。
    override func motionEnded(_ motion: UIEvent.EventSubtype, 
            with event: UIEvent?) {
        super.motionEnded(motion, with: event)

        if motion == .motionShake {
            if let undoManager = UIApplication.shared.firstResponder?.undoManager {
                UIAlertController(undoManager: undoManager)?.present()
            }
        }
    }

}

接著,我們要告訴 UIKit 去用 Window,而方法是把一個 Window 的實體,指派給 AppDelegatewindow 屬性:

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow? = Window()

    // ...

}

當然,這是在使用 storyboard 的情況下。如果沒有用 storyboard 的話,那就只要把原本的 UIWindow 建構式改成 Window 建構式就可以了:

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {

        let window = Window()
        window.rootViewController = RootViewController()
        window.makeKeyAndVisible()
        self.window = window
    }

}

如此一來,UIKit 就會使用我們的 Window,而不是原本的 UIWindow,來建構 app 的主視窗了。

用 UIApplication 攔截

用主視窗攔截已經是很棒的解法了,但如果你夠 hardcore,想照 UIKit 原本的設計,將攔截搖動事件的職責交給 UIApplication 的話,你會需要做更多步驟。

首先,一樣是先 subclass UIApplication

class Application: UIApplication {

    override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
        super.motionEnded(motion, with: event)

        if motion == .motionShake {

            // 唯一的差別是不需要再呼叫 UIApplication.shared 了,因為它就是 self。
            if let undoManager = firstResponder?.undoManager {
                UIAlertController(undoManager: undoManager)?.present()
            }
        }
    }

}

接著,我們需要手動指定 UIApplication 的 subclass,而這是透過在一個叫做 main.swift 的檔案裡實作 UIApplicationMain(_:_:_:_:) 這個函式所達成的:

import UIKit

UIApplicationMain(
    CommandLine.argc,
    CommandLine.unsafeArgv,
    NSStringFromClass(Application.self),
    NSStringFromClass(AppDelegate.self)
)

如果實作了這個函式的話,我們就得把 AppDelegate@UIApplicationMain 特性拿掉:

import UIKit

class AppDelegate: UIResponder, UIApplicationDelegate {

    // ...

}

如此一來,UIKit 就會使用 Application 來啟動應用程式了!

更臻完美

到此為止,我們已經可以在搖動手機的時候去顯示還原警告了,但有些行為還是跟內建的不太一樣。首先,還原警告顯示的時候沒有震動回饋,而這只要在攔截處改成如下即可:

    override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
        super.motionEnded(motion, with: event)

        if motion == .motionShake {

            if let undoManager = firstResponder?.undoManager,
                let alertController = UIAlertController(undoManager: undoManager) {
                UINotificationFeedbackGenerator().notificationOccurred(.success)
                alertController.present()
            }
        }
    }

另外,為了要避免還原警告重複出現,我們可以再加一個判斷式:

    // weak 的屬性很適合拿來確認所參照的物件存在與否。
    weak var undoAlertController: UIAlertController?

    override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
        super.motionEnded(motion, with: event)

        if motion == .motionShake {

            // 當 self.undoAlertController 的內容是 nil 的時候才會顯示警告。
            if let undoManager = firstResponder?.undoManager,
                let alertController = UIAlertController(undoManager: undoManager),
                self.undoAlertController == nil {

                UINotificationFeedbackGenerator().notificationOccurred(.success)
                alertController.present()
                self.undoAlertController = alertController
            }
        }
    }

接下來⋯

透過重新實作整個搖動還原功能,你有沒有更了解 UIKit 裡第一響應者的設計模式了呢?現在我們已經掌控了整個機制的程式碼,接下來,我們可以開始試著玩玩它,比如說,將搖動偵測改成按鈕偵測,或者把還原的 UI 改掉。剛好最近著名繪圖 app Procreate 的開發團隊釋出了該 app 的還原手勢,是用兩指與三指輕點去執行還原與重做,你也可以試著將它套用到我們介紹的設計模式看看啊!

作者
Hsu Li-Heng
iOS 開發者、寫作者、filmmaker。現正負責開發 Storyboards by narrativesaw 此一故事板文件 app 中。深深認同 Swift 對於程式碼易讀性的重視。個人網站:lihenghsu.com。電郵:[email protected]
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。