I want to implement undo/redo operations in my app. The undo/redo actions need to be recorded for every document rather than at UIViewController level. So I will need to put UndoManager in the class implementing custom document. But one of the requirement is that undo/redo should be possible even when the app is restarted, which means UndoManager must be persistent. How do we make UndoManager state persistent?
UndoManager persistence in iOS
1000 views Asked by Deepak Sharma AtThere are 2 answers
On
You could encode some persistable data like a string containing the action name and item ID in the undo action's actionName property and persist this list, into say user defaults. Since we don't have access to the undo stack you would need to fake undo the whole stack to get all the action names, i.e. disable some object in the methods so that the actions have no effect. This could be done when the scene enters background. Then at enter foreground you could re-register all the undo actions using this info. The app user interface would also have to be restored but that is a whole other can of worms. You would also have to subclass the NSUndoManager and override undoMenuTitleForUndoActionName to decode a real name for the undo menu from the encoded action. You probably would need to persist the stack per scene. Then persisting redo is probably possible but a bit more complicated, when entering background you would have to redo all the way forward then undo all the way back and also remember what action you were on, then when entering foreground register all the actions and then undo back to where you were.
Here is a working example I threw together:
- (void)sceneWillEnterForeground:(UIScene *)scene {
// Called as the scene transitions from the background to the foreground.
// Use this method to undo the changes made on entering the background.
self.managedObjectContext = ((AppDelegate *)UIApplication.sharedApplication.delegate).persistentContainer.viewContext;
NSArray *array = [NSUserDefaults.standardUserDefaults objectForKey:@"UndoStack"];
if (array == nil) {
return;
}
for (NSDictionary *dict in array) {
NSString *s = dict[@"identifier"];
NSUUID *identifier = [NSUUID.alloc initWithUUIDString:s];
[self.undoManager beginUndoGrouping];
if ([dict[@"insert"] boolValue]) {
[self.undoManager registerUndoWithTarget:self selector:@selector(undoablyDeleteItemWithIdentifier:) object:identifier];
}
else {
[self.undoManager registerUndoWithTarget:self selector:@selector(undoablyUndeleteItemWithIdentifier:) object:identifier];
}
[self.undoManager endUndoGrouping];
}
[NSUserDefaults.standardUserDefaults removeObjectForKey:@"UndoStack"];
[NSUserDefaults.standardUserDefaults synchronize];
}
- (void)sceneDidEnterBackground:(UIScene *)scene {
// Called as the scene transitions from the foreground to the background.
// Use this method to save data, release shared resources, and store enough scene-specific state information
// to restore the scene back to its current state.
self.managedObjectContext = nil;
NSMutableArray *array = NSMutableArray.array;
while (self.undoManager.canUndo) {
NSString *s = self.undoManager.undoActionName;
if (s.length == 0) {
continue;
}
NSData *data = [s dataUsingEncoding:NSUTF8StringEncoding];
NSError *error;
NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
[array addObject:dict];
[self.undoManager undo];
}
[NSUserDefaults.standardUserDefaults setObject:array forKey:@"UndoStack"];
[NSUserDefaults.standardUserDefaults synchronize];
// Save changes in the application's managed object context when the application transitions to the background.
[(AppDelegate *)UIApplication.sharedApplication.delegate saveContext];
}
- (void)insertNewObject:(id)sender {
Item *item = [self insertItemWithIdentifier: NSUUID.alloc.init];
[self.undoManager registerUndoWithTarget:self selector:@selector(undoablyDeleteItemWithIdentifier:) object:item.identifier];
NSDictionary *dict = @{@"insert" : @YES, @"identifier" : item.identifier.UUIDString};
NSError *error;
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:dict options:0 error:&error];
NSString *jsonString = [NSString.alloc initWithData:jsonData encoding:NSUTF8StringEncoding];
[self.undoManager setActionName: jsonString];
}
- (Item *)insertItemWithIdentifier:(NSUUID *)identifier {
NSManagedObjectContext *context = self.managedObjectContext;
Item *newItem = [[Item alloc] initWithContext:context];
// If appropriate, configure the new managed object.
//newItem.timestamp = [NSDate date];
newItem.identifier = identifier;
// Save the context.
NSError *error = nil;
if (![context save:&error]) {
// Replace this implementation with code to handle the error appropriately.
// abort() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
NSLog(@"Unresolved error %@, %@", error, error.userInfo);
abort();
}
return newItem;
}
- (void)undoablyDeleteItemWithIdentifier:(NSUUID *)identifier {
NSManagedObjectContext *context = self.managedObjectContext;
if (context == nil) {
return;
}
Item *item = [self itemWithIdentifier:identifier];
[context deleteObject:item];
[self.undoManager registerUndoWithTarget:self selector:@selector(undoablyUndeleteItemWithIdentifier:) object:item.identifier];
// Save the context.
NSError *error = nil;
if (![context save:&error]) {
// Replace this implementation with code to handle the error appropriately.
// abort() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
NSLog(@"Unresolved error %@, %@", error, error.userInfo);
abort();
}
}
- (void)undoablyUndeleteItemWithIdentifier:(NSUUID *)identifier {
Item *item = [self insertItemWithIdentifier:identifier];
[self.undoManager registerUndoWithTarget:self selector:@selector(undoablyDeleteItemWithIdentifier:) object:item.identifier];
}
- (Item *)itemWithIdentifier:(NSUUID *)identifier {
NSManagedObjectContext *context = self.managedObjectContext;
NSFetchRequest *fr = Item.fetchRequest;
fr.predicate = [NSPredicate predicateWithFormat:@"identifier = %@", identifier];
NSError *error = nil;
NSArray *results = [context executeFetchRequest:fr error:&error];
if (error) {
NSLog(@"Unresolved error %@, %@", error, error.userInfo);
abort();
}
Item *item = results.firstObject;
return item;
}
As far as I know, The UndoManager does not directly relate to your data model, hence does not directly relate to your app state - i.e the data model state. It merely allows you to register some actions that will alter your data model in an undo/redo fashion. To achieve persistency, while using the UndoManager, you will have to use some persistent store (a DB like Core Data or Realm, or for a very simple state you can use UserDefaults). You will have to make the actions you register with your UndoManager to update the persistent store you choose to use. In addition, you will have to somehow keep track of the changes in the DB so you can restore and register them back between application launches.
An example I found online that describes the Undo/Redo actions registrations - taken from this example - https://medium.com/flawless-app-stories/undomanager-in-swift-5-with-simple-example-8c791e231b87
As you can see, the actions will just change the state and the UndoManager does not keep track of the state, just the actions...
Some personal opinions: For complex logic and state, I personally like to use some state management frameworks like Redux (or the Swift equivalent ReSwift), in this case, I can easily create an Undo/Redo actions. And you can easily keep a record of the state along the application lifecycle, and record it to a persistent store, for example, make your state Codable and keep a stack of it in storage. It kind of bits the purpose of using the UndoManager - but as far as I know you cannot keep the registered actions to disk.