Hai
Hai

Reputation: 37

Control the state of ViewController in ReactiveCocoa

I am implementing the simple PincodeViewController. It looks like:

(just a random image from Google, but mine is the same)

It has 3 steps: enter current pincode -> enter new pincode -> confirm.

Currently, I created 3 element signals, like

RACSignal *enter4digit = ... // input 4 digits
RACSignal *passcodeCorrect = ... // compare with stored pincode
RACSignal *pincodeEqual = ... // confirm pincode in step 3

And bind them together

RACSignal *step1 = [RACSignal combineLatest:@[enter4digit, passcodeCorrect]];
RACSignal *step2 = [RACSignal combineLatest:@[enter4digit, stage1]];
RACSignal *step3 = [RACSignal combineLatest:@[enter4digit, pincodeEqual, stage2]];

It doesn't work. How can I deal with it?

Any advice would be appreciated. Thank you.

Upvotes: 0

Views: 141

Answers (1)

MeXx
MeXx

Reputation: 3357

The problem here is that you have one input signal (the 4 digit input) and based on what what was sent on that signal before (as well as the initial current pincode), different things should happen.

The complex solution

You could approach this by decomposing this into different signals as you started, in that case you would need to pick out the first, second and third value on the pincodeInput signal and do different things with those, e.g. to create the pincodeCorrect signal, you would:

RACSignal *firstInput = [enter4digit take:1];
RACSignal *pincodeCorrect = [[[RACSignal return:@(1234)] combineLatestWith:firstInput] map:^NSNumber *(RACTuple *tuple) {
    RACTupleUnpack(NSNumber *storedPincode, NSNumber *enteredPincode) = tuple;
    return @([storedPincode isEqualToNumber:enteredPincode]);
}];

where 1234 is the current pincode.

And for pincodeEqual you need the second and third values of enter4digit:

RACSignal *secondInput = [[[enter4digit skip:1] take:1] replayLast];
RACSignal *thirdInput = [[[enter4digit skip:2] take:1] replayLast];

RACSignal *pincodeEqual = [[secondInput combineLatestWith:thirdInput] map:^NSNumber *(RACTuple *tuple) {
    RACTupleUnpack(NSNumber *pincode, NSNumber *pincodeConfirmation) = tuple;
    return @([pincode isEqualToNumber:pincodeConfirmation]);
}];

Binding them together gets a little bit more complicated though. It can be done with the if:then:else operator. I'm creating another intermediate signal for the new pincode to make the code more readable.

IF the pincodeEqual is YES (which is the case when the second and third value on enter4digit are equal) THEN we return the value, ELSE we return a signal that immediatly sends an error. Here, the replyLast on secondInput and thirdInput are important, because that value is needed several time after the event was sent!

NSError *confirmationError = [NSError errorWithDomain:@"" code:0 userInfo:@{NSLocalizedDescriptionKey: @"Confirmation Wrong"}];
RACSignal *changePincode = [RACSignal
                            if:pincodeEqual
                            then:thirdInput
                            else:[RACSignal error:confirmationError]];

In order to get the actual whole process, we do almost the same again:

NSError *pincodeError = [NSError errorWithDomain:@"" code:1 userInfo:@{NSLocalizedDescriptionKey: @"Pincode Wrong"}];
RACSignal *newPincode = [RACSignal
                         if:pincodeCorrect
                         then:changePincode
                         else:[RACSignal error:pincodeError]];

The signal newPincode will now EITHER send the value of the new pincode AFTER the whole process was successful, OR an error (either that the current pincode was wrong, or that the confirmation was wrong)

Using a State Machine

That all being said, I think the solution above is very convoluted, its easy to make mistakes and hard to wire the rest of your UI to the process.

It can be greatly simplified (in my opinion) by modelling the problem as a State Machine.

FSM

So, you'll have a state, and based on that state and an input, the state changes.

typedef NS_ENUM(NSInteger, MCViewControllerState) {
    MCViewControllerStateInitial,
    MCViewControllerStateCurrentPincodeCorrect,
    MCViewControllerStateCurrentPincodeWrong,
    MCViewControllerStateNewPincodeEntered,
    MCViewControllerStateNewPincodeConfirmed,
    MCViewControllerStateNewPincodeWrong
};

Well, actually, the states changes base on the current state, the current input and the previous input. So let's create signals for all these:

RACSignal *state = RACObserve(self, state);
RACSignal *input = enter4digit;
RACSignal *previousInput = [input startWith:nil];

We'll later zip these together, by starting input with nil, previousInput is always exactly 1 value behind input, thus when a new value arrives on input, the previous value will be sent on previousInput at the same time.

Now, we'll need to combine these three together to create the new state:

RAC(self, state) = [[RACSignal zip:@[state, input, previousInput]] map:^NSNumber *(RACTuple * tuple) {
    RACTupleUnpack(NSNumber *currentStateNumber, NSNumber *pincodeInput, NSNumber *previousInput) = tuple;
    MCViewControllerState currentState = [currentStateNumber integerValue];

    // Determine the new state based on the current state and the new input
    MCViewControllerState nextState;

    switch (currentState) {
        case MCViewControllerStateInitial:
            if ([pincodeInput isEqualToNumber:storedPincode]) {
                nextState = MCViewControllerStateCurrentPincodeCorrect;
            } else {
                nextState = MCViewControllerStateCurrentPincodeWrong;
            }
            break;
        case MCViewControllerStateCurrentPincodeCorrect:
            nextState = MCViewControllerStateNewPincodeEntered;
            break;
        case MCViewControllerStateNewPincodeEntered:
            if ([pincodeInput isEqualToNumber:previousInput]) {
                nextState = MCViewControllerStateNewPincodeConfirmed;
            } else {
                nextState = MCViewControllerStateNewPincodeWrong;
            }
            break;

        default:
            nextState = currentState;
    }

    return @(nextState);
}];

By using zip, we will calculate a new state with map exactly once for each new value that arrives on input. Inside map weg always get the current state, the current input and the previous input and can now calculate the next state based on those three values.

Now its easier to update your UI by observing self.state again and updating your UI accordingly, e.g. displaying an error message or offering a restart to start over (essentially by resetting the state machine to the initial state). Especially the latter would be much harder to do in the first solution because there, we're skipping explicit specific numbers on the input signal (and then even terminating)...

Upvotes: 1

Related Questions