Approach to Reactifying delegate methods with side

2019-03-13 15:46发布

问题:

Just trying to wrap my head around the ReactiveCocoa approach to certain situations.

I have a situation where a segment controller swaps out children view controllers. I need to accomplish a couple things here:

  1. When moved to the parent controller, I must update the contentInset of the tableView because iOS7 doesn't handle it for me with custom container views
  2. When search is initiated, I need to fade the navigation bar, and update the contentInset with animation
  3. When search ends, I need to fade the navigationBar back in and reset the contentInset in an animation

Here is the current code that accomplishes this in an imperative style:

- (void)didMoveToParentViewController:(UIViewController *)parent
{
    [super didMoveToParentViewController:parent];
    if (parent) {
        CGFloat top = parent.topLayoutGuide.length;
        CGFloat bottom = parent.bottomLayoutGuide.length;
        if (self.tableView.contentInset.top != top) {
            UIEdgeInsets newInsets = UIEdgeInsetsMake(top, 0, bottom, 0);
            self.tableView.contentInset =  newInsets;
            self.tableView.scrollIndicatorInsets = newInsets;
        }
    }
}

- (BOOL)searchBarShouldBeginEditing:(UISearchBar *)searchBar {
    [UIView animateWithDuration:.25 animations:^{
        self.navigationController.navigationBar.alpha=0;
        self.tableView.contentInset = UIEdgeInsetsMake([UIApplication sharedApplication].statusBarFrame.size.height, 0, 0, 0);
    }];
    return YES;
}
- (BOOL)searchBarShouldEndEditing:(UISearchBar *)searchBar {
    [UIView animateWithDuration:.25 animations:^{
        self.navigationController.navigationBar.alpha=1;
        CGFloat top = self.parentViewController.topLayoutGuide.length;
        CGFloat bottom = self.parentViewController.bottomLayoutGuide.length;
        if (self.tableView.contentInset.top != top) {
            UIEdgeInsets newInsets = UIEdgeInsetsMake(top, 0, bottom, 0);
            self.tableView.contentInset =  newInsets;
            self.tableView.scrollIndicatorInsets = newInsets;
        }
    }];
    return YES;
}

It could be refactored to pull some of the inset stuff out, but keeping it flat for this exercise.

Going to post my 'very little idea what I'm doing' approach as an answer below.

Partial Answer

Ok so I'm trying to pull out streams of information into relevant signals.

Basically I need to know:

  1. Am I currently searching
  2. The current value of my contentInset (top) in this case

So my approach would be

  1. Create a RACSubject for whether or not I am currently searching self.currentlySearchingSignal.
  2. Turn the top value of my tableView.contentInset into a signal
  3. sendNext:@(YES) to currentlySearchingSignal when searchBarShouldBeginEditing is called (and when it will return YES)
  4. sendNext:@(NO) to currentlySearchingSignal when searchBarShouldEndEditing is called (and when it will return YES)
  5. ...

Ok I'm stuck. I know I need to combine/subscribe to these somehow, but trying to think of it in a non-state way.

  1. When added to the parent VC AND when my contentInset.top isn't yet set properly (topLayoutGuide), I need to set it without an animation.
  2. When searching AND my contentInset.top isn't set properly (status bar frame) I need to perform the animation (and then not update this again until my animation is done)
  3. When not searching AND my contentInset.top isn't set properly (topLayoutGuide) I need to perform the animation (and not update again until the animation is done)

Trying to solve it

Here's my start. Trying to solve for #1, but it's not working yet.

- (void)viewDidLoad
{
    [super viewDidLoad];
    @weakify(self);
    self.currentlyInSearchMode = [RACSubject subject];
    self.contentInsetTop = RACObserve(self.tableView, contentInset);
    RACSignal *parentViewControllerSignal = RACObserve(self, parentViewController);

    // setup the insets when added to parent and not correctly set yet
    [[[RACSignal combineLatest:@[self.contentInsetTop, parentViewControllerSignal]] filter:^BOOL(RACTuple *value) {
        return !((NSValue *)value.first).UIEdgeInsetsValue.top == ((UIViewController *)value.second).topLayoutGuide.length;
    }]doNext:^(id x) {
        @strongify(self);
        CGFloat top = self.parentViewController.topLayoutGuide.length;
        CGFloat bottom = self.parentViewController.bottomLayoutGuide.length;
        if (self.tableView.contentInset.top != top) {
            UIEdgeInsets newInsets = UIEdgeInsetsMake(top, 0, bottom, 0);
            self.tableView.contentInset =  newInsets;
            self.tableView.scrollIndicatorInsets = newInsets;
        }
    }];

}

回答1:

Here's my answer, as copied from the GitHub issue:

I haven't used ReactiveCocoaLayout, but I suspect you may find some of this code could be improved by using RCL, in addition to RAC. I'm sure someone else will provide more detail about that.

The first thing I'd suggest is reading up on -rac_signalForSelector:. It's enormously valuable for bridging between delegate callbacks and RAC signal.

For example, here's how you get signals that represent your desired callbacks:

RACSignal *movedToParentController = [[self
    rac_signalForSelector:@selector(didMoveToParentViewController:)]
    filter:^(RACTuple *arguments) {
        return arguments.first != nil; // Ignores when parent is `nil`
    }];

RACSignal *beginSearch = [self rac_signalForSelector:@selector(searchBarShouldBeginEditing:)];
RACSignal *endSearch = [self rac_signalForSelector:@selector(searchBarShouldEndEditing:)];

Now, let's say you have a method that updates the view:

- (void)updateViewInsets:(UIEdgeInsets)insets navigationBarAlpha:(CGFloat)alpha animated:(BOOL)animated {
    void (^updates)(void) = ^{
        if (self.tableView.contentInset.top != insets.top) {
            self.tableView.contentInset = insets;
            self.tableView.scrollIndicatorInsets = insets;
        }
        self.navigationController.navigationBar.alpha = alpha;
    };

    animated ? [UIView animateWithDuration:0.25 animations:updates] : updates();
}

Now, you can use start to put a few things together.

First, since -searchBarShouldBeginEditing: is the shortest:

[beginSearch subscribeNext:^(id _) {
    UIEdgeInsets insets = UIEdgeInsetsMake(UIApplication.sharedApplication.statusBarFrame.size.height, 0, 0, 0);
    [self updateViewInsets:insets navigationBarAlpha:0 animated:YES];
}];

Now, for the more complicated piece. This signal composition starts by +mergeing two signals, the signal for -didMoveToParentViewController: and the signal for searchBarShouldEndEditing:. Each of these signals is mapped to the appropriate parent view controller, and paired with a boolean indicating whether to perform animation. This pair of values is packed into a RACTuple.

Next, using -reduceEach:, the (UIViewController *, BOOL) tuple is mapped into a (UIEdgeInsets, BOOL) tuple. This mapping calculates the edge insets from the parent view controller, but doesn't change animated flag.

Finally, this signal composition is subscribed to, wherein the helper method is called.

[[[RACSignal
    merge:@[
        [movedToParentController reduceEach:^(UIViewController *parent) {
            return RACTuplePack(parent, @NO); // Unanimated
        }],
        [endSearch reduceEach:^(id _) {
            return RACTuplePack(self.parentViewController, @YES); // Animated
        }]
    ]]
    reduceEach:^(UIViewController *parent, NSNumber *animated) {
        CGFloat top = parent.topLayoutGuide.length;
        CGFloat bottom = parent.bottomLayoutGuide.length;
        UIEdgeInsets newInsets = UIEdgeInsetsMake(top, 0, bottom, 0);
        return RACTuplePack(([NSValue valueWithUIEdgeInsets:newInsets]), animated);
    }]
    subscribeNext:^(RACTuple *tuple) {
        RACTupleUnpack(NSValue *insets, NSNumber *animated) = tuple;
        [self updateViewInsets:insets.UIEdgeInsetsValue navigationBarAlpha:1 animated:animated.boolValue];
    }];

You'll find with RAC there are often alternative approaches that you can take. The more you experiment, the more you'll discover what works, what doesn't work, the nuances, etc.

PS. Appropriate @weakify/@strongify is left as an exercise.

FOLLOW UP ANSWER

Another approach is to use -rac_liftSelector:. Here's how it could be used for the code you've provided. It's very similar to the code above, except you extract the animated flag into its own signal, instead of nesting it into the signal that calculates the insets.

RACSignal *insets = [RACSignal
    merge:@[
        [movedToParentController reduceEach:^(UIViewController *parent) {
            return parent;
        }],
        [endSearch reduceEach:^(id _) {
            return self.parentViewController;
        }]
    ]]
    map:^(UIViewController *parent) {
        CGFloat top = parent.topLayoutGuide.length;
        CGFloat bottom = parent.bottomLayoutGuide.length;
        UIEdgeInsets newInsets = UIEdgeInsetsMake(top, 0, bottom, 0);
        return [NSValue valueWithUIEdgeInsets:newInsets];
    }];

RACSignal *animated = [RACSignal merge:@[
    [movedToParentController mapReplace:@NO],
    [endSearch mapReplace:@YES],
];

RACSignal *alpha = [RACSignal return:@1];

[self rac_liftSelector:@selector(updateViewInsets:navigationBarAlpha:animated:) withSignals:insets, alpha, animated, nil];

IMO, neither approach is a clear winner over the other. The guidelines however do recommend avoiding explicit subscription.