5
votes

I'm building an application using ReactiveCocoa. The top view is a menu which can be pulled down then pushed back up. I have to use two different gesture recognizers – one for pulling down and one for pushing back up. Only one can be enabled at a time – and there's my problem. State.

I'm using the BlocksKit extension to set up the gesture recognizer.

self.panHeaderDownGestureRecognizer = [[UIPanGestureRecognizer alloc] initWithHandler:^(UIGestureRecognizer *sender, UIGestureRecognizerState state, CGPoint location) {
    UIPanGestureRecognizer *recognizer = (UIPanGestureRecognizer *)sender;

    CGPoint translation = [recognizer translationInView:self.view];

    if (state == UIGestureRecognizerStateChanged)
    {
        [self.downwardHeaderPanSubject sendNext:@(translation.y)];
    }
    else if (state == UIGestureRecognizerStateEnded)
    {
        // Determine the direction the finger is moving and ensure if it was moving down, that it exceeds the minimum threshold for opening the menu.
        BOOL movingDown = ([recognizer velocityInView:self.view].y > 0 && translation.y > kMoveDownThreshold);

        // Animate the change
        [UIView animateWithDuration:0.25f animations:^{
            if (movingDown)
            {
                [self.downwardHeaderPanSubject sendNext:@(kMaximumHeaderTranslationThreshold)];
            }
            else
            {
                [self.downwardHeaderPanSubject sendNext:@(0)];
            }
        } completion:^(BOOL finished) {
            [self.menuFinishedTransitionSubject sendNext:@(movingDown)];
        }];
    }
}];

In my initWithNibName:bundle: method, I'm setting up the following RACSubjects.

self.headerMovementSubject = [RACSubject subject];
[self.headerMovementSubject subscribeNext:^(id x) {
    @strongify(self);

    // This is the ratio of the movement. 0 is closed and 1 is open.
    // Values less than zero are treated as zero.
    // Values greater than one are valid and will be extrapolated beyond the fully open menu.
    CGFloat ratio = [x floatValue];

    CGRect headerFrame = CGRectMake(0, 0, CGRectGetWidth(self.view.bounds), kHeaderHeight + ratio * kMaximumHeaderTranslationThreshold);

    if (ratio < 0)
    {            
        headerFrame = CGRectMake(0, 0, CGRectGetWidth(self.view.bounds), kHeaderHeight);
    }

    self.headerViewController.view.frame = headerFrame;
}];

// This subject is responsible for receiving translations from a gesture recognizers and turning
// thos values into ratios. These ratios are fead into other signals.
self.downwardHeaderPanSubject = [RACSubject subject];
[self.downwardHeaderPanSubject subscribeNext:^(NSNumber *translation) {
    @strongify(self);
    CGFloat verticalTranslation = [translation floatValue];

    CGFloat effectiveRatio = 0.0f;

    if (verticalTranslation <= 0)
    {
        effectiveRatio = 0.0f;
    }
    else if (verticalTranslation <= kMaximumHeaderTranslationThreshold)
    {
        effectiveRatio = fabsf(verticalTranslation / kMaximumHeaderTranslationThreshold);
    }
    else
    {
        CGFloat overshoot = verticalTranslation - kMaximumHeaderTranslationThreshold;
        CGFloat y = 2 * sqrtf(overshoot + 1) - 2;
        effectiveRatio = 1.0f + (y / kMaximumHeaderTranslationThreshold);
    }

    [self.headerMovementSubject sendNext:@(effectiveRatio)];
}];

// This subject is responsible for mapping this value to other signals and state (ugh). 
self.menuFinishedTransitionSubject = [RACReplaySubject subject];
[self.menuFinishedTransitionSubject subscribeNext:^(NSNumber *menuIsOpenNumber) {
    @strongify(self);

    BOOL menuIsOpen = menuIsOpenNumber.boolValue;

    self.panHeaderDownGestureRecognizer.enabled = !menuIsOpen;
    self.panHeaderUpGestureRecognizer.enabled = menuIsOpen;
    self.otherViewController.view.userInteractionEnabled = !menuIsOpen;

    if (menuIsOpen)
    {
        [self.headerViewController flashScrollBars];
    }
}];

There's a lot going on here. The problem is exacerbated by the fact that I've got nearly double the number of subjects as I've listed here (ones for the pan-up gesture recognizer, too), plus another set of recognizers for similar interaction with the footer. That's a lot of subjects.

My question is in two parts:

  1. Is there a better way to set up the kind of chaining I want? I'm re-using some of the subjects in my push-up gesture, as well, which looks really similar. I've got a lot of RACSubjects and it seems janky.
  2. The menuFinishedTransitionSubject is essentially used for managing the state of the gesture recognizers. I tried binding their enabled property without any luck. Any advice here?
1

1 Answers

5
votes

Let's focus on the explicit subscriptions, because those are generally the low-hanging fruit for rewriting imperative code.

First of all, based on the code shown, it looks like headerMovementSubject is only fed values from downwardHeaderPanSubject (and nowhere else). That's an easy candidate for writing as a transformation instead:

RACSignal *headerFrameSignal = [[self.downwardHeaderPanSubject
    map:^(NSNumber *translation) {
        CGFloat verticalTranslation = [translation floatValue];
        CGFloat effectiveRatio = 0.0f;

        // Calculate effectiveRatio.

        return @(effectiveRatio);
    }]
    map:^(NSNumber *effectiveRatio) {
        // Calculate headerFrame.

        return @(headerFrame);
    }];

Then, instead of manipulating self.headerViewController.view.frame as a side effect, we can use a binding:

RAC(self.headerViewController.view.frame) = headerFrameSignal;

We can do similar things with the booleans in menuFinishedTransitionSubject:

RAC(self.panHeaderDownGestureRecognizer.enabled) = [self.menuFinishedTransitionSubject not];
RAC(self.panHeaderUpGestureRecognizer.enabled) = self.menuFinishedTransitionSubject;
RAC(self.otherViewController.view.userInteractionEnabled) = [self.menuFinishedTransitionSubject not];

Unfortunately, -flashScrollBars still needs to be invoked as a side effect, but we can at least lift the filtering out of the block:

[[self.menuFinishedTransitionSubject
    filter:^(NSNumber *menuIsOpen) {
        return menuIsOpen.boolValue;
    }]
    subscribeNext:^(id _) {
        @strongify(self);

        [self.headerViewController flashScrollBars];
    }];

If you want to get really fancy, a lot of the gesture recognizer logic can be represented with stream transformations instead, and the animation could be implemented with ReactiveCocoaLayout, but that's a rewrite of its own.