使用 NSUndoManager 來進行撤銷和重做

作者:Tomasz Szulc,原文鏈接,原文日期:2015-09-13
譯者:Yake;校對:numbbbbb;定稿:

在相當長的一段時間內 NSUndoManager 對我來說都是一個很神秘的東西。我想學習使用它,但是一直沒有時間。一直到今天,我寫了一個簡單的應用,這個應用創建了一個可以移動的矩形,并且還可以修改矩形的屬性,例如背景色或者圓角。

你可以在這兒獲取到示例應用
這兒是一段小視頻,你可以看看這個示例應用是怎么工作的。

Screen-Shot-2015-09-13-at-17.24.19.png
Screen-Shot-2015-09-13-at-17.24.19.png

NSUndoManager

NSUndoManager允許記錄用戶執行的操作并且反轉這類操作。

當你調用一個可以改變一些東西的方法或者是執行一個改變屬性值的動作(例如 setter 方法)時,你可以注冊這個操作來進行撤銷。

一個撤銷操作包含了接收消息的對象,發送消息以及參數 - 通常你會傳入原始值。

NSUndoManager實例支持重做操作,所以才能逆轉操作。你可以認為這個管理器擁有兩個棧。實際上,它管理兩個棧,undo(撤銷)棧和redo(重做)棧 - 對應NSUndoManager的私有屬性_undoStack_redoStack,里面存儲著一些操作。

注冊undo操作時,它會被添加到undo棧中。當調用undo()方法時管理器就會進行撤銷,執行棧中的操作并把這個操作移動到redo棧中,這樣你就可以重做它。當你擁有多個undo操作時,按照逆序來撤銷和重做這些操作。你肯定不會將操作直接注冊到redo棧中,實際上這根本無法實現。

你可以為undo操作設置一個級別,這指的是一個管理器可以在它的棧中存儲多少undo操作。如果添加的undo操作數量超過了這個級別,最早加入的那個操作將會從棧中移除。

你可以通過canUndocanRedo來檢查undoredo棧的狀態。這些狀態很重要,你可能會基于這些棧的狀態來更新 UI。

假設你已經設置了undo操作的級別并且redo棧中還有一個操作,如果undo操作超過了這個級別,那你就需要使用canUndocanRedo來檢測可用性。之所以要這樣做,是因為在這種情況下NSUndoManager將會移除redo操作,因為它是歷史操作中最新的undo操作。(校對注:這里確實很繞,大家可以類比一下編輯器的撤銷和重做操作,如果你在撤銷之后進行了新的改動,那之前撤銷過的操作其實已經無法再被重做了,因此可以被直接刪掉,從而把更多的空間留給undo操作。)

注冊undo操作

API提供了兩種注冊操作的方法。

第一種是使用registerUndoWithTarget(_:selector:object:)方法:

func registerUndoAddFigure(figure: FigureView) {
    undoManager.registerUndoWithTarget(self, selector: Selector(“removeFigure:”), object: figure)
    undoManager.setActionName(“Add Figure”)
}

第二種撤銷方法是基于NSInvocation。你可以使用prepareWithInvocationTarget(_:)方法來注冊此類操作。

func registerUndoAddFigure(figure: FigureView) {
    undoManager.prepareWithInvocationTarget(self).removeFigure(figure)
    undoManager.setActionName("Add Figure")
}

你將會得到一個NSUndoManagerProxy類型對象,可以用它調用任何方法(但是只能調用目標對象遵守的那些協議方法,否則應用會拋出運行時異常)。注冊之后代理對象將會在內部創建NSInvocation對象來記錄你的操作,這個對象會在傳入的目標對象執行undo操作時被調用。

值得強調的是,在注冊過程中目標對象沒有被持有,需要你去管理它。如果undo操作被調用而目標對象已經被銷毀,就會產生運行時異常。

當對象將要被銷毀時你需要調用removeAllActionsWithTarget(_:)來移除與目標對象相關聯的一些操作,或者調用removeAllActions()來移除undoredo棧中所有的操作

將操作分組

分組操作是一件很有用的事情。默認情況下操作是通過事件進行分組的。這就意味著操作將會通過每一輪運行時循環來分組。你可以關閉自動分組,調用beginUndoGrouping()endUndoGrouping()方法來手動操作分組。

命名并顯示操作

NSUndoManager支持存儲操作的名稱。你可以調用setActionName(_:)方法來為操作命名。管理器已經包含UndoRedo這兩個單詞的多語言版本,可以使用 API 直接獲取對應語言的Undo/Redo字符串。

下面這個方法來自示例應用,每一個新的undo操作被注冊或者undo``redo操作被執行之后,將會更新undoredo按鈕。

private func updateUndoAndRedoButtons() {
    undoButton.enabled = undoManager.canUndo == true
    if undoManager.canUndo {
        undoButton.setTitle(undoManager.undoMenuTitleForUndoActionName(undoManager.undoActionName), forState: .Normal)
    } else {
        undoButton.setTitle(undoManager.undoMenuItemTitle, forState: .Normal)
    }
    
    redoButton.enabled = undoManager.canRedo == true
    if undoManager.canRedo {
        redoButton.setTitle(undoManager.redoMenuTitleForUndoActionName(undoManager.redoActionName), forState: .Normal)
    } else {
        redoButton.setTitle(undoManager.redoMenuItemTitle, forState: .Normal)
    }
}

通知

管理器有幾個你可以觀察的通知類型。在示例應用中我關注的是NSUndoManagerDidUndoChangeNotificationNSUndoManagerDidRedoChangeNotification。為了讓應用完美運行,我可能需要觀察所有will或者did類型的通知,因為操作可能要執行一段時間,并且一部分代碼可能是異步的。在這些情況下應用要正確展示 UI 就需要使用這些通知來刷新UndoRedo按鈕。

上下文

應用在不同的上下文中可能有不同的管理器。示例應用在不同的上下文中用了兩個管理器。

第一個上下文是塊展板,展板用來展示矩形并且可以在上面移動這個矩形。在這個展板上下文中可能發生的操作就是添加、移動或者移除一個矩形。

第二個上下文是這個矩形自己。你可以改變它的顏色和圓角。我決定追蹤展板的背景色以及圓角,忽略掉它在展板中的位置。

這樣你就可以添加一個矩形,移動它,改變它的顏色和圓角,使用undo來撤銷移動操作但是不會撤銷掉背景色和圓角的改變。你所使用的上下文數量取決于你的應用是怎么樣的。

響應鏈

每一個UIView對象繼承自UIResponder類型,這個類定義了響應對象的接口并且處理事件。

UIResponder類聲明了undoManager屬性。當應用接收到undo事件,UIResponder搭建起響應者鏈并通過undoManager返回一個NSUndoManager類型的對象來找到這個響應者。找到的第一個響應者將被用來處理undo或者redo操作。

為了使用響應者鏈你需要重載canBecomeFirstResponder()屬性并且設置返回值為true,然后通過調用becomeFirstResponder()方法使持有undoManager的對象成為第一響應者。如果你已經正確設置好了一切,可以執行一個搖晃手勢,應用會出現一個警告框詢問你是否要執行undo操作。

圖片二.png
圖片二.png

示例代碼

當我寫這個示例代碼的時候我注意自己花費了很多時間去考慮“具有唯一目的”的方法。當你需要支持撤銷和重做操作時這其實很重要,因為你調用那些方法就是出于特定的目的。

下面的示例代碼來自那個示例應用,它展示了在展板中的添加,移除以及移動操作都是怎么實現的。下面是所有與undo manager相關的代碼:

/// MARK: Actions on Figures
func addFigure(figure: FigureView) {
    registerUndoAddFigure(figure)
    
    boardView.addSubview(figure)
    figures.append(figure)
    
    updateUndoAndRedoButtons()
}
 
func removeFigure(figure: FigureView) {
    registerUndoRemoveFigure(figure)
    
    figure.removeFromSuperview()
    if let index = figures.indexOf(figure) {
        figures.removeAtIndex(index)
    }
}
 
func moveFigure(figure: FigureView, center: CGPoint) {
    registerUndoMoveFigure(figure)
    figure.center = center
}
 
/// MARK: Undo Manager
override func canBecomeFirstResponder() -> Bool {
    return true
}
 
private var _undoManager = NSUndoManager()
override var undoManager: NSUndoManager {
    return _undoManager
}
 
private func observeUndoManager() {
    NSNotificationCenter.defaultCenter().addObserver(self, selector: Selector("updateUndoAndRedoButtons"), name: NSUndoManagerDidUndoChangeNotification, object: undoManager)
    NSNotificationCenter.defaultCenter().addObserver(self, selector: Selector("updateUndoAndRedoButtons"), name: NSUndoManagerDidRedoChangeNotification, object: undoManager)
}
 
@objc private func updateUndoAndRedoButtons() {
    undoButton.enabled = undoManager.canUndo == true
    if undoManager.canUndo {
        undoButton.setTitle(undoManager.undoMenuTitleForUndoActionName(undoManager.undoActionName), forState: .Normal)
    } else {
        undoButton.setTitle(undoManager.undoMenuItemTitle, forState: .Normal)
    }
    
    redoButton.enabled = undoManager.canRedo == true
    if undoManager.canRedo {
        redoButton.setTitle(undoManager.redoMenuTitleForUndoActionName(undoManager.redoActionName), forState: .Normal)
    } else {
        redoButton.setTitle(undoManager.redoMenuItemTitle, forState: .Normal)
    }
}
 
/// MARK: Undo Manager Actions
func registerUndoAddFigure(figure: FigureView) {
    undoManager.prepareWithInvocationTarget(self).removeFigure(figure)
    undoManager.setActionName("Add Figure")
}
 
func registerUndoRemoveFigure(figure: FigureView) {
    undoManager.prepareWithInvocationTarget(self).addFigure(figure)
    undoManager.setActionName("Remove Figure")
}
 
func registerUndoMoveFigure(figure: FigureView) {
    undoManager.prepareWithInvocationTarget(self).moveFigure(figure, center: figure.center)
    undoManager.setActionName("Move to \(figure.center)")
}

我創建了一些undo相關的簡單方法。這樣做效果很好,注冊undo操作的邏輯和操作本身的邏輯相分離,代碼精簡為一句函數調用。

我決定放棄使用registerUndoWithTarget(_:selector:object:)方法,因為Selector是一個字符串,這樣做很危險。而prepareWithInvocationTarget(_:)看起來更好一些,既安全又便于使用。

不過,當你需要設置屬性時可能要用帶Selector的方法。

你需要直接調用想要記錄的方法,但是這樣做不能設置屬性(因為只能調用方法)。有兩種解決方法:第一種是添加類似setPropertyName(_:)的方法,第二種是使用registerUndoWithTarget(_:selector:object:)方法并將Selector設置為setPropertyName:,作為參數傳入。

結論

NSUndoManager是一種強大的機制,我們可以簡單地向應用中加入undoredo方法。它需要你謹慎地設計應用的結構,因為你需要使用“具有唯一目的性”的方法來將用戶的操作設置為undo或者是redo。但是總體來說這是個好事,不是嗎?這會改善代碼設計。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,983評論 6 537
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,772評論 3 422
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,947評論 0 381
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,201評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,960評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,350評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,406評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,549評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,104評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,914評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,089評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,647評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,340評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,753評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,007評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,834評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,106評論 2 375

推薦閱讀更多精彩內容