7
votes

The UIScrollView has a lot of information available to the programmer, but I dont see an obvious way to control the location that the control stop at after decelerating from a scroll gesture.

Basically I would like the scrollview to snap to specific regions of the screen. The user can still scroll like normal, but when they stop scrolling the view should snap to the most relevant location, and in the case of a flick gesture the deceleration should stop at these locations too.

Is there an easy way to do something like this, or should I consider the only way to accomplish this effect to write a custom scrolling control?

4

4 Answers

27
votes

Since the UITableView is a UIScrollView subclass, you could implement the UIScrollViewDelegate method:

- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView 
    withVelocity:(CGPoint)velocity 
    targetContentOffset:(inout CGPoint *)targetContentOffset

And then compute what the closest desired target content offset is that you want, and set that on the inout CGPoint parameter.

I've just tried this and it works well.

First, retrieve the unguided offset like this:

CGFloat unguidedOffsetY = targetContentOffset->y;

Then Figure out through some math, where you'd want it to be, noting the height of the table header. Here's a sample in my code dealing with custom cells representing US States:

CGFloat guidedOffsetY;

if (unguidedOffsetY > kFirstStateTableViewOffsetHeight) {
    int remainder = lroundf(unguidedOffsetY) % lroundf(kStateTableCell_Height_Unrotated);
    log4Debug(@"Remainder: %d", remainder);
    if (remainder < (kStateTableCell_Height_Unrotated/2)) {
        guidedOffsetY = unguidedOffsetY - remainder;
    }
    else {
        guidedOffsetY = unguidedOffsetY - remainder + kStateTableCell_Height_Unrotated;
    }
}
else {
    guidedOffsetY = 0;  
}

targetContentOffset->y = guidedOffsetY;

The last line above, actually writes the value back into the inout parameter, which tells the scroll view that this is the y-offset you'd like it to snap to.

Finally, if you're dealing with a fetched results controller, and you want to know what just got snapped to, you can do something like this (in my example, the property "states" is the FRC for US States). I use that information to set a button title:

NSUInteger selectedStateIndexPosition = floorf((guidedOffsetY + kFirstStateTableViewOffsetHeight) / kStateTableCell_Height_Unrotated);
log4Debug(@"selectedStateIndexPosition: %d", selectedStateIndexPosition);
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:selectedStateIndexPosition inSection:0];
CCState *selectedState = [self.states objectAtIndexPath:indexPath];
log4Debug(@"Selected State: %@", selectedState.name);
self.stateSelectionButton.titleLabel.text = selectedState.name;

OFF-TOPIC NOTE: As you probably guessed, the "log4Debug" statements are just logging. Incidentally, I'm using Lumberjack for that, but I prefer the command syntax from the old Log4Cocoa.

7
votes

After the scrollViewDidEndDecelerating: and scrollViewDidEndDragging:willDecelerate: (the last one just when the will decelerate parameter is NO) you should set the contentOffset parameter of your UIScrollView to the desired position.

You also will know the current position by checking the contentOffset property of your scrollview, and then calculate the closest desired region that you have

Although you don't have to create your own scrolling control, you will have to manually scroll to the desired positions

7
votes

To add to what felipe said, i've recently created a table view that snaps to cells in a similar way the UIPicker does. A clever scrollview delegate is definitely the way to do this (and you can also do that on a uitableview, since it's just a subclass of uiscrollview).

I had this done by, once the the scroll view started decelerating (ie after scrollViewDidEndDragging:willDecelerate: is called), responding to scrollViewDidScroll: and computing the diff with the previous scroll event.

When the diff is less than say a 2 to 5 of pixels, i check for the nearest cell, then wait until that cell has been passed by a few pixels, then scroll back in the other direction with setContentOffset:animated:. That creates a little bounce effect that is very nice for user experience, as it gives a good feedback on the snapping.

You'll have to be clever and not do anything when the table is bouncing at the top or bottom (comparing the offset to 0 or the content size will tell you that). It works pretty well in my case because the cells are small (about 80-100px high), you might run into problems if you have a regular scroll view with bigger content areas. Of course, you will not always decelerate past a cell, so in this case i just scroll to the nearest cell, and the animation looks jerky. Turns out with the right tuning, it barely ever happens, so i'm cool with this. Spend a few hours tuning the actual values depending on your specific screen and you can get something decent.

I've also tried the naive approach, calling setContentOffset:animated: on scrollViewDidEndDecelerating: but it creates a really weird animation (or just plain confusing jump if you don't animate), that gets worse the lower the deceleration rate is (you'll be jumping from a slow movement to a much faster one).

So to answer the question: - no, there is no easy way to do this, it'll take some time polishing the actual values of the previous algorithm, which might not work at all on your screen, - don't try to create your own scroll view, you'll just waste time and badly reinvent a beautiful piece of engineering apple created with truck loads of bug. The scrollview delegate is the key to your problem.

6
votes

Try something like this:

- (void) snapScroll;
{
     int temp = (theScrollView.contentOffset.x+halfOfASubviewsWidth) / widthOfSubview;
    theScrollView.contentOffset = CGPointMake(temp*widthOfSubview , 0);
}

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

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

This takes advantage of the int's drop of the post-decimal digits. Also assumes all your views are lined up from 0,0 and only the contentOffset is what makes it show up in different areas.

Note: hook up the delegate and this works perfectly fine. You're getting a modified version - mine just has the actual constants lol. I renamed the variables so you can read it easy