你也可以自訂搖動還原 (Shake to Undo) 的功能?一起來拆解並實作吧!
在 iOS 上編輯內容的時候,如果要還原或重做步驟的話,通常可以透過搖動來呼叫出一個還原的警告:
這個搖動還原 (Shake to Undo) 功能在 UITextView
或者 UITextField
等文字編輯的 view 上是內建的,但大多數其他的 view 都沒有預設實作。還好,UIKit 其實已經幫我們做好了從動作偵測到顯示警告的部分,我們只需要提供第一響應者 (First Responder),並使用它的 undoManager
就可以了。
使用系統提供的搖動還原
第一步,就是去啟用 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()
這兩個方法在 UITextView
與 UITextField
上面,其實就是進入與退出編輯模式的意思。
從這點來說,第一響應者雖然表面上只是第一個接收動作與鍵盤等輸入的物件,但它其實更常代表的是現在正在被編輯的物件。比如說,除了剛剛提到的文字編輯物件之外,當我們想要針對某個響應者物件去顯示編輯選單的時候,我們也是要先使該物件成為第一響應者,好讓 UIMenuController
知道它正在編輯的對象是誰,要把編輯動作傳送給哪個響應者。同樣的概念也可以被套用在搖動還原上 ── 我們使要被還原/重做的響應者成為第一響應者,代表我們向 UIApplication
宣告說這個響應者正在被編輯,要它針對該第一響應者去顯示還原警告。
現在,我們已經大致知道 UIKit 內建的搖動還原,是由 UIApplication
在接收到搖動動作之後,去針對第一響應者的 undoManager
顯示一個警告。畫成圖的話就是這樣:
平常的時候,我們並不用知道這圖中的各個步驟要怎麼做到,因為 UIApplication
的 applicationSupportsShakeToEdit
已經都幫你實作好了。然而,它使用起來雖簡單,它的 UI 卻幾乎沒有自訂的空間,因為我們完全無法取得它所呈現的 UIAlertController
物件。不只是沒有公開 API 而已,是連該警告物件所在的視窗都沒有在 UIApplication.shared.windows
裡面。
讓我們執行一個簡單的檢查。用 Xcode 啟動模擬器或實機上的 app,並呼叫出搖動還原的警告之後,執行 Xcode 裡的 Debug View Hierarchy 功能:
沒錯,該警告根本沒有在整個 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 的職責通常已經很重,再加上這段程式碼很可能會讓它更為龐大、紊亂。所以,在職責相對輕的 UIWindow
與 UIApplication
實作可能是更好的選擇。唯一的問題是,要攔截動作事件就必須要寫 subclass,但 UIWindow
跟 UIApplication
可以被 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
的實體,指派給 AppDelegate
的 window
屬性:
@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 的還原手勢,是用兩指與三指輕點去執行還原與重做,你也可以試著將它套用到我們介紹的設計模式看看啊!