Reactivecocoa: How do you handle commands that require confirmation?

Created on 10 Mar 2014  ·  9Comments  ·  Source: ReactiveCocoa/ReactiveCocoa

I'm sure there must be a more reactive way of dealing with this but I can't get my head around it.

I have a UIButton that performs a destructive action - a deletion, in this case. My view model has a deleteCommand property that returns a command that performs the deletion and wiring the button to the command is straightforward.

However, what I'd really like to happen is when the button is tapped, a confirmation UIAlertView is displayed and if the user hits OK, the deletion is performed or if they hit Cancel, it is not.

This "confirmation" behaviour feels like it belongs in the view model as it's a fundamental part of my view's behaviour - I do not want to allow deletion without confirmation first.

However, the specifics of how I obtain that confirmation (e.g. a UIAlertView) do not belong in the view model as it shouldn't know anything about the view. It seems like the actual presentation of the alert belongs in the view controller.

So is there a nice, elegant way of doing this, such that I can still have one single RACCommand that represents my deletion with confirmation that I can bind to my UIButton?

question

All 9 comments

From your view model expose alerts signal sending AlertViewModels. Set appropriate command for each button. This is the solution I use in my app.

This "confirmation" behaviour feels like it belongs in the view model as it's a fundamental part of my view's behaviour - I do not want to allow deletion without confirmation first.

You'd like to model the "safety being off" in you view model? It could be a good idea if you have different views displaying this kind of view model, but don't bind it too tightly with the idea of confirmation: if you display your model in a table view swiping left to display the delete button would already serve as removing the safety: no need to ask the user for confirmation again when he taps it.

I personally would just go with a simpler solution: bind the button's enabled to the command, but not the command itself, and manually call the command's -execute: from the UIAlertView.

Yes basically what Denis said. I've done this multiple times.
On your view model you have:

  1. A command for your initial action (deleting in this case).
  2. A signal that sends a value when a confirmation is required
  3. A single command for confirmation, or a command for accept and deny

Code would look something like this (Untested, and without the strongify/weakify necessary):

self.deleteCommand = [[RACCommand alloc] initWithSignalBlock:^RACSignal *(id objectToDelete) { 
    [return self.confirmationCommand.executionSignals
        switchToLatest]
            flattenMap:^id(NSNumber *confirmed) {
                if(confirmed) {
                    return [self deleteObject:objectToDelete];
                } else {
                    return [RACSignal error:someErrorWithAMessageMaybe];
                }
            }];
}];

self.confirmationRequiredSignal = [self.deleteCommand.executing
    filter: ^BOOL(NSNumber *executing) {
        return executing.boolValue;
    }];

So your delete button in your view controller starts executes deleteCommand on the view model, which immediately starts executing. The delete command is now waiting for confirmationCommand.executionSignals to send along a value. The executing of the delete command triggers a value to be sent on self.confirmationRequiredSignal.

Then your view controller observes that confirmationRequiredSignal on the viewModel, throws up an alert view and ties the buttons on the alert view to the confirmationCommand (confirmation command would get the index of the button by default I believe, so you would have to map that into your confirmed value). Once the user taps a button, self.confirmationCommand is executed, and returns the confirmed value, thus completing the cycle. You can optionally return an error in the deleteCommand like I've done here if you wanted to maybe throw an error message up or something by observing self.deleteCommand.errors. Or you could just return an empty signal to complete the execution of the command.

This model works with alert views, action sheets, etc. RACCommands are really freaking cool.

@Coneko is probably right that a simple confirmation doesn't necessarily belong in the view model. But it's still a good method to have in your back pocket for things like selecting from multiple values to continue. e.g. Someone taps the log in with twitter button, and they need to choose which twitter account to use (if they have multiple) to continue (or cancel). (A real example I just built for.)

@sprynmr thanks for the feedback, I went with a similar, but IMO slightly simpler solution.

I replaced the property with a method called deleteWithConfirmation: which takes a single argument - a block that returns an RACSignal representing the confirmation by sending a single value, @YES, or @NO

The command itself is a signal that takes the first value from the confirmation signal and maps it to an appropriate signal - the deletion signal if confirmed else an empty signal:

- (RACCommand *)deleteWithConfirmation:(RACSignal*(^)(void))signalBlock
{
    NSParameterAssert(signalBlock);

    @weakify(self);

    return [[RACCommand alloc] initWithEnabled:nil signalBlock:^RACSignal *(id input) {
        @strongify(self);

        RACSignal *confirmationSignal = signalBlock();

        return [[confirmationSignal take:1] flattenMap:^RACStream *(id value) {
            if ([value boolValue]) {
                return [self doDeletion]; // for example
            }
            else {
                return [RACSignal empty];
            }
        }];
    }];
}

In the view controller, I wire up the command to my button like so:

self.deleteButton.rac_command = [self.viewModel deleteWithConfirmation:^RACSignal *{
        UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Delete Widget" message:@"Are you sure you want to delete this widget?" delegate:nil cancelButtonTitle:@"Cancel" otherButtonTitles:@"Delete", nil];

        [alert show];

        return [alert.rac_buttonClickedSignal map:^id(NSNumber *buttonIndex) {
            return @([buttonIndex integerValue] == 1);
        }];
    }];

As you can see, the signal I return is just a map from the button clicked signal to a YES/NO value.

Works well enough for me! This still seems reasonably flexible as I could simply pass in a block that returns a signal that sends a single YES value if I wanted to skip confirmation. I could even encapsulate this as a separate method on my view model, for example:

- (RACCommand *)deleteWithoutConfirmationCommand
{
    return [self deleteWithConfirmation:^RACSignal *{
        return [RACSignal return:@YES];
    }]
}

Seems like a pretty good solution as well. I'll file that away.

@sprynmr I did try your answer. However, there is something puzzling me.
The signal returned by the block will never complete (self.confirmationCommand.executionSignals.switchToLatest). It means the command will always stay in executing mode, so it cannot execute any other commands.

How do you make the following code work in practice?

self.deleteCommand = [[RACCommand alloc] initWithSignalBlock:^RACSignal *(id objectToDelete) { 
    [return self.confirmationCommand.executionSignals
        switchToLatest]
            flattenMap:^id(NSNumber *confirmed) {
                if(confirmed) {
                    return [self deleteObject:objectToDelete];
                } else {
                    return [RACSignal error:someErrorWithAMessageMaybe];
                }
            }];
}];

@haifengkao add something to control the lifecycle, like a take:1 after switchToLatest

Also watch out for a retain cycle there. @strongify/weakify your multiple references to self

Was this page helpful?
0 / 5 - 0 ratings