Episode #97

Scrolling Nub

23 minutes
Published on November 28, 2013

This video is only available to subscribers. Get access to this video and 572 others.

In this episode I implement a fast scrolling "nub" to assist with scrolling through table views with many entries. The technique was lifted from the Dropbox app and I build a quick prototype of how it works.

Episode Links

Hiding / Showing the Nub

First we have a couple of helper methods we'll use:

- (void)animateNumbAlpha:(CGFloat)alpha {
    [UIView animateWithDuration:0.5 animations:^{
        self.scrollingNub.alpha = alpha;
    }];
}

- (void)hideNubAfterDelay {
    double delayInSeconds = 0.75;
    dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC));
    dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
        if (!self.scrubbing && ![self.tableView isDecelerating]) {
            [self animateNumbAlpha:0];
        }
    });
}

We star the nub out hidden, so we want to show it when we scroll:

- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
    if (self.scrollingNub.alpha == 0) {
        [self animateNumbAlpha:1];
    }
}

Then we want it to hide when we stop dragging, and when the scroll view comes to a rest:


- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
    if (!decelerate) {
        [self hideNubAfterDelay];
    }
}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
    [self hideNubAfterDelay];
}

This solution works okay, but as you saw in the video it needs a bit more work.

Updating the nub's position as you scroll

In our scroll event we'll reposition the nub. The only part that changes is the y value, so we extract that into a method.

- (CGFloat)nubY {
    CGFloat minY = self.tableView.contentInset.top + 10;
    CGFloat maxY = self.view.bounds.size.height - self.scrollingNub.bounds.size.height - 10;

    return [self percentageThroughContent] * (maxY - minY ) + minY;
}

The y value here is dependent on the scroll position, which itself is described in terms of the percent through the content. We calculate that in the following method:

- (CGFloat)percentageThroughContent {
    CGFloat bottomY = self.tableView.contentSize.height - self.tableView.bounds.size.height;
    if (bottomY == 0) {
        return 0;
    }

    return self.tableView.contentOffset.y / bottomY;
}

With this we can see the nub moving as we scroll.

Dragging the nub

We need to attach a PanGestureRecognizer to the nub so we can grab it with our finger.

    UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self
                                                                          action:@selector(onNubScrub:)];
    [self.scrollingNub addGestureRecognizer:pan];
    if (pan.state == UIGestureRecognizerStateBegan) {
        self.scrollingNub.backgroundColor = [UIColor blueColor];
        self.scrubbing = YES;
    } else if (pan.state != UIGestureRecognizerStateChanged) {
        self.scrollingNub.backgroundColor = [UIColor greenColor];
        self.scrubbing = NO;
        [self hideNubAfterDelay];
    }

    CGFloat translation = [pan translationInView:self.view].y;

    CGFloat minY = self.tableView.contentInset.top + 10;
    CGFloat maxY = self.view.bounds.size.height - self.scrollingNub.bounds.size.height - 10;

    CGRect rect = self.scrollingNub.frame;
    rect.origin.y += translation;
    rect.origin.y = MAX(rect.origin.y, minY);
    rect.origin.y = MIN(rect.origin.y, maxY);
    self.scrollingNub.frame = rect;

    // y = p * (maxY - minY) + minY
    // y - minY
    // ---------     = p
    // (maxY - minY)
    CGFloat percent = (rect.origin.y - minY ) / (maxY - minY );
    percent = MIN(percent, 1);
    percent = MAX(percent, 0);

    CGFloat minOffset = - self.tableView.contentInset.top;
    CGFloat maxOffset = self.tableView.contentSize.height - self.tableView.bounds.size.height;
    CGFloat scrollOffset = percent * (maxOffset - minOffset) + minOffset;

    CGPoint offset = CGPointMake(0, scrollOffset);
    [self.tableView setContentOffset:offset];

    [pan setTranslation:CGPointZero inView:self.view];
}

The equation used here to calculate the scroll percentage is derived from the equation we used to set the nub y position. There's certainly some duplication here that could be refactored, but for now the math is easy enough to understand.

Now we have a working prototype.

The next step here would be to figure out the proper way to extract this code into a component that we can apply more easily, rather than dumping all of this code in a view controller.