NSUndoManager
We all make mistakes. Thankfully, Foundation comes to our rescue for more than just our misspellings. Cocoa includes a simple yet robust API for undoing or redoing actions through NSUndo
.
By default, each application window has an undo manager, and any object in the responder chain can manage a custom undo manager for performing undo and redo operations local to their respective view. UIText
and UIText
use this functionality to automatically provide support for undoing text edits while first responder. However, indicating whether other actions can be undone is an exercise left for the app developer.
Creating an undoable action requires three steps: performing a change, registering an “undo operation” which can reverse the change, and responding to a request to undo the change.
Undo Operations
To show an action can be undone, register an “undo operation” while performing the action. The Undo Architecture documentation defines an “undo operation” as:
A method for reverting a change to an object, along with the arguments needed to revert the change.
The operation specifies:
- The object to receive a message if an undo is requested
- The message to send and
- The arguments to pass with the message
If the method invoked by the undo operation also registers an undo operation, the undo manager provides redo support without extra work, as it is “undoing the undo”.
There are two types of undo operations, “simple” selector-based undo and complex “NSInvocation-based undo”.
Registering a Simple Undo Operation
To register a simple undo operation, invoke NSUndo
on a target which can undo the action. The target is not necessarily the modified object, and is often a utility or container which manages the object’s state. Specify the name of the undo action at the same time, using NSUndo
. The undo dialog shows the name of the action, so it should be localized.
func update Score(score: NSNumber) {
undo Manager.register Undo With Target(self, selector:Selector("update Score:"), object:my Movie.score)
undo Manager.set Action Name(NSLocalized String("actions.update", comment: "Update Score"))
my Movie.score = score
}
- (void)update Score:(NSNumber*)score {
[undo Manager register Undo With Target:self selector:@selector(update Score:) object:my Movie.score];
[undo Manager set Action Name:NSLocalized String(@"actions.update", @"Update Score")];
my Movie.score = score;
}
Registering a Complex Undo Operation with NSInvocation
Simple undo operations may be too rigid for some uses, as undoing an action may require more than one argument. In these cases, we can leverage NSInvocation
to record the selector and arguments required. Calling prepare
records which object will receive the message which will make the change.
func move Piece(piece: Chess Piece, row:UInt, column:UInt) {
let undo Controller : View Controller = undo Manager?.prepare With Invocation Target(self) as View Controller
undo Controller.move Piece(piece, row:piece.row, column:piece.column)
undo Manager?.set Action Name(NSLocalized String("actions.move-piece", "Move Piece"))
piece.row = row
piece.column = column
update Chessboard()
}
- (void)move Piece:(Chess Piece*)piece to Row:(NSUInteger)row column:(NSUInteger)column {
[[undo Manager prepare With Invocation Target:self] move Piece:piece To Row:piece.row column:piece.column];
[undo Manager set Action Name:NSLocalized String(@"actions.move-piece", @"Move Piece")];
piece.row = row;
piece.column = column;
[self update Chessboard];
}
The magic here is that NSUndo
implements forward
. When the undo manager receives the message to undo -move
, it forwards the message to the target since NSUndo
does not implement this method.
Performing an Undo
Once undo operations are registered, actions can be undone and redone as needed, using NSUndo
and NSUndo
.
Responding to the Shake Gesture on iOS
By default, users trigger an undo operation by shaking the device. If a view controller should handle an undo request, the view controller must:
- Be able to become first responder
- Become first responder once its view appears,
- Resign first responder when its view disappears
When the view controller then receives the motion event, the operating system presents a dialog to the user when undo or redo actions are available. The undo
property of the view controller will handle the user’s choice without further involvement.
class View Controller: UIView Controller {
override func view Did Appear(animated: Bool) {
super.view Did Appear(animated)
become First Responder()
}
override func view Will Disappear(animated: Bool) {
super.view Will Disappear(animated)
resign First Responder()
}
override func can Become First Responder() -> Bool {
return true
}
…
}
@implementation View Controller
- (void)view Did Appear:(BOOL)animated {
[super view Did Appear:animated];
[self become First Responder];
}
- (void)view Will Disappear:(BOOL)animated {
[super view Will Disappear:animated];
[self resign First Responder];
}
- (BOOL)can Become First Responder {
return YES;
}
…
@end
Customizing the Undo Stack
Grouping Actions Together
All undo operations registered during a single run loop will be undone together, unless “undo groups” are otherwise specified. Grouping allows undoing or redoing many actions at once. Although each action can be performed and undone individually, if the user performs two at once, undoing both at once preserves a consistent user experience.
func read And Archive Email(email: Email) {
undo Manager?.begin Undo Grouping()
mark Email(email, read: true)
archive Email(email)
undo Manager?.set Action Name(NSLocalized String("actions.read-archive", comment:"Mark as Read and Archive"))
undo Manager?.end Undo Grouping()
}
func mark Email(email: Email, read:Bool) {
let undo Controller: View Controller = undo Manager?.prepare With Invocation Target(self) as View Controller
undo Controller.mark Email(email, read:email.read)
undo Manager?.set Action Name(NSLocalized String("actions.read", comment:"Mark as Read"))
email.read = read
}
func archive Email(email: Email) {
let undo Controller: View Controller = undo Manager?.prepare With Invocation Target(self) as View Controller
undo Controller.move Email(email, to Folder:"Inbox")
undo Manager?.set Action Name(NSLocalized String("actions.archive", comment:"Archive"))
move Email(email, to Folder:"All Mail")
}
- (void)read And Archive Email:(Email*)email {
[undo Manager begin Undo Grouping];
[self mark Email:email as Read:YES];
[self archive Email:email];
[undo Manager set Action Name:NSLocalized String(@"actions.read-archive", @"Mark as Read and Archive")];
[undo Manager end Undo Grouping];
}
- (void)mark Email:(Email*)email as Read:(BOOL)is Read {
[[undo Manager prepare With Invocation Target:self] mark Email:email as Read:[email is Read]];
[undo Manager set Action Name:NSLocalized String(@"actions.read", @"Mark as Read")];
email.read = is Read;
}
- (void)archive Email:(Email*)email {
[[undo Manager prepare With Invocation Target:self] move Email:email to Folder:@"Inbox"];
[undo Manager set Action Name:NSLocalized String(@"actions.archive", @"Archive")];
[self move Email:email to Folder:@"All Mail"];
}
Clearing the Stack
Sometimes the undo manager’s list of actions should be cleared to avoid confusing the user with unexpected results. The most common cases are when the context changes dramatically, like changing the visible view controller on iOS or externally made changes occurring on an open document. When that time comes, the undo manager’s stack can be cleared using NSUndo
or NSUndo
if finer granularity is needed.
Caveats
If an action has different names for undo versus redo, check whether an undo operation is occurring before setting the action name to ensure the title of the undo dialog reflects which action will be undone. An example would be a pair of opposing operations, like adding and removing an object:
func add Item(item: NSObject) {
undo Manager?.register Undo With Target(self, selector: Selector("remove Item:"), object:item)
if undo Manager?.undoing == false {
undo Manager?.set Action Name(NSLocalized String("action.add-item", comment: "Add Item"))
}
my Array.append(item)
}
func remove Item(item: NSObject) {
if let index = find(my Array, item) {
undo Manager?.register Undo With Target(self, selector: Selector("add Item:"), object:item)
if undo Manager?.undoing == false {
undo Manager?.set Action Name(NSLocalized String("action.remove-item", comment: "Remove Item"))
}
my Array.remove At Index(index)
}
}
- (void)add Item:(id)item {
[undo Manager register Undo With Target:self selector:@selector(remove Item:) object:item];
if (![undo Manager is Undoing]) {
[undo Manager set Action Name:NSLocalized String(@"actions.add-item", @"Add Item")];
}
[my Array add Object:item];
}
- (void)remove Item:(id)item {
[undo Manager register Undo With Target:self selector:@selector(add Item:) object:item];
if (![undo Manager is Undoing]) {
[undo Manager set Action Name:NSLocalized String(@"actions.remove-item", @"Remove Item")];
}
[my Array remove Object:item];
}
If your test framework runs many tests as a part of one run loop (like Kiwi), clear the undo stack between tests in teardown
. Otherwise tests will share undo state and invoking NSUndo
during a test may lead to unexpected results.
There are even more ways to refine behavior with NSUndo
, particularly for grouping actions and managing scope. Apple also provides usability guidelines for making undo and redo accessible in an expected and delightful way.
We all may wish to live without mistakes, but Cocoa gives us a way to let our users live with fewer regrets as it makes some actions easily changeable.