Pierre Bernard
Pierre Bernard

Reputation: 3198

UndoManager with async or long-running tasks

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
            }
        }

Upvotes: 0

Views: 329

Answers (2)

Pierre Bernard
Pierre Bernard

Reputation: 3198

Since the November 2021 solution no longer works, I have come up with a new one.

I now register with the undo manager as soon as the task starts. Again, when the operation is undo, I also register immediately with the undo manager.

I have created HHUndoableTask object that keeps track of the operation's state. The background task can check if the operation has been cancelled. The undo operation can check if the task is done and thus ready for undoing.

@implementation HHUndoableTask

NSString *const HHUndoableTaskRegisteredTasksKey =  @"HHUndoableTask.registeredTasks";

- (NSMutableArray *)registeredTasksForUndoManager:(NSUndoManager *)undoManager
{
    NSMutableArray *registeredTasks = [undoManager hh_associatedValueForKey:HHUndoableTaskRegisteredTasksKey];

    if (registeredTasks == nil) {
        registeredTasks = [NSMutableArray array];

        [undoManager hh_setAssociatedValue:registeredTasks forKey:HHUndoableTaskRegisteredTasksKey];
    }

    return registeredTasks;

}
- (void)registerWithUndoManager:(NSUndoManager *)undoManager handler:(void (^)(HHUndoableTask *undoableTask))handler
{
    NSMutableArray *registeredTasks = [self registeredTasksForUndoManager:undoManager];

    [undoManager registerUndoWithTarget:self handler:handler];

    [registeredTasks insertObject:self atIndex:0];

    NSInteger levelsOfUndo = (undoManager.levelsOfUndo != 0) ? undoManager.levelsOfUndo : 100;

    while ([registeredTasks count] > levelsOfUndo) {
        HHUndoableTask *last = [registeredTasks lastObject];

        [registeredTasks removeLastObject];
        [undoManager removeAllActionsWithTarget:last];
    }
}

- (void)removeFromUndoManager:(NSUndoManager *)undoManager
{
    NSMutableArray *registeredTasks = [self registeredTasksForUndoManager:undoManager];

    [undoManager removeAllActionsWithTarget:self];

    [registeredTasks removeObject:self];
}

@end

This is used in code that starts the long-running operation:

- (void)start
{
    NSMutableDictionary *fileTagNames = [NSMutableDictionary dictionary];

    HHUndoableTask *undoableTask = [[HHUndoableTask alloc] init];

    undoableTask.data = fileTagNames;

    self.undoableTask = undoableTask;

    BOOL isUndoManagerOperation = undoManager.undoing || undoManager.isRedoing;

    if (!isUndoManagerOperation) {
        [undoManager setActionName:[NSString stringWithFormat:@"Tag %ld Files", [self.fileURLs count]]];
    }

    __weak NSUndoManager *weakUndoManager = undoManager;

    [undoableTask registerWithUndoManager:undoManager handler:^(HHUndoableTask *undoableTask) {
        NSDictionary *fileTagNames = undoableTask.data;
        NSUndoManager *undoManager = weakUndoManager;

        if ((![fileTagNames isKindOfClass:[NSDictionary class]]) || (undoManager == nil)) {
            return;
        }

        undoableTask.undone = true;

        if (!undoableTask.ready) {
            return;
        }

        // TODO: Call reverse operation
    }];

}

and once the operation completes:

- (void)stop
{
    HHUndoableTask  *undoableTask   = self.undoableTask;

    NSDictionary    *fileTagNames   = self.undoableTask.data;

    if ([fileTagNames count] > 0) {
        undoableTask.ready = !undoableTask.undone;
    }
    else {
        dispatch_async(dispatch_get_main_queue(), ^(void) {
            [undoableTask removeFromUndoManager:undoManager];
        });
    }
}

Upvotes: 0

Pierre Bernard
Pierre Bernard

Reputation: 3198

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:

if (undoManager.undoing || undoManager.redoing) {
    NSObject *dummy = [[NSObject alloc] init];

    [undoManager registerUndoWithTarget:dummy selector:@selector(description) object:nil];
    [undoManager performSelector:@selector(removeAllActionsWithTarget:)
                      withObject:dummy
                      afterDelay:0.0];
}

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.

if (self.undoing) {
    [[undoManager prepareWithInvocationTarget:[self class]] dummyTaskWithArguments:arguments];
    [undoManager undo];
}
else {
    [[undoManager prepareWithInvocationTarget:[self class]] taskWithArguments:arguments];
}

+ (void)dummyTaskWithArguments:(id)arguments
{
    [[undoManager prepareWithInvocationTarget:[self class]] taskWithArguments:arguments];
}

Upvotes: 1

Related Questions