I have problems using UndoManager / NSUndoManager with async or long-running task. I have a solution that works, but is quite complicated - way more than what seems reasonable for a rather common problem. I'll post that as an answer and hope for better ones.
Problem 1:
My undoable task does not complete in the current runloop. Such a task can be a short operation with a callback that is called asynchrously. It can also be a long-running operation for which I may show a progress indicator or even offer the option to cancel.
Problem 2:
My undoable task may fail or be canceled. Or worse, the redo task could fail. Example: I move a file, upon undo I discover the file is gone from the new location. I should not put a redo task back on the stack.
Idea 1:
I could put undo/redo registration at the completion of the task. One cannot undo an operation that has not yet completed, was canceled, or has failed. With this setup, I cannot get an operation and its undo operation to pair up correctly: redo does not work. Example: the user asks for a file to be copied. At the end of the copy operation, I register the operation with UndoManager. The user chooses to undo. I again wait until the operation has completed to register with UndoManager. Now the UndoManager does not know that the file deletion that has just completed is actually the reverse operation for the previous copy operation. Rather than offer the user the option to redo the copy, it offers the option to undo the deletion
Idea 2:
Disable automatic undo grouping. I fail to see how I could do so with a long-running operation. I want automatic grouping for most other task.
I could not get this to work with a simple operation with an asnyc callback. This throw: "endUndoGrouping called with no matching begin"
let assets = PHAsset.fetchAssets(in: album, options: nil)
let parent = PHCollectionList.fetchCollectionListsContaining(album, options: nil).firstObject
if let undoManager = undoManager {
undoManager.groupsByEvent = false
undoManager.beginUndoGrouping()
let isUndoManagerOperation = undoManager.isUndoing || undoManager.isRedoing
let targetSelf = Controller.self as AnyObject
undoManager.registerUndo(withTarget: targetSelf) { [weak undoManager] targetSelf in
Controller.createAlbum(for: assets, title: album.localizedTitle, parent: parent, with: undoManager, completionHandler: nil)
}
if !isUndoManagerOperation {
undoManager.setActionName(NSLocalizedString("Delete Album", comment: "Undoable action: Delete Album"))
}
}
PHPhotoLibrary.shared().performChanges {
PHAssetCollectionChangeRequest.deleteAssetCollections(NSArray.init(object: album))
} completionHandler: { (success, error) in
DispatchQueue.main.async {
undoManager?.endUndoGrouping()
undoManager?.groupsByEvent = true
}
}
This is a convoluted solution. It works, but it is at best an innovative hack.
Basics:
I register with the NSUndoManager only after the long-running task has completed. The problem that then arises is that the symmetric undo operation is also a long-running task and also registers after completion. NSUndoManager sees two separate opertions rather than an (re)do/undo pair.
Hack 1:
At the start of the operation (initial operation, or undo operation), I check if the UndoManger is currently undoing or redoing. It then expects the reverse operation to be registered. It expects the current undo operation to be paired/balanced with a redo operation. I give it a dummy operation:
I then remove that operation from the undo stack. The undo stack is now in a reasonable / consistent state. I, however have lost the ability to redo the operation I am currently undoing.
Hack 2:
When an undo task completes, I cannot simply register with the undo manager: that was already done (and cleared) as the task started. Instead, I register a task that does nothing but again register with the undo manager. Then let the undo manager undo. The idea: I fake doing the original operation, so that when that is undone, I can register the redo operation.