在 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 的還原手勢,是用兩指與三指輕點去執行還原與重做,你也可以試著將它套用到我們介紹的設計模式看看啊!