Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to make a UIScrollView snap to icons (like App Store: Feature)

What I want to get is the same behaviour that this scroll view has:

App Store feature scroll view (left)

I know that this is using HTML and not the native API, but I'm trying to implement it as a UIKit component.

Now, to the behaviour I'm looking for:

  • Notice that it's a paged scroll view, but the "page size" is less than the view's width.
  • When you scroll it from left to right each page "snap" to the left-most item.
  • When you scroll it from the right end to the left it "snaps" to the right-most item.

The same page but now right-to-left:

App Store feature scroll view (right)

What I've tried:

  • I've tried making the scroll view smaller than it's super view and overriding hitTest, and that got me that left-to-right behaviour.
  • I've tried implementing scrollViewWillEndDragging:withVelocity:targetContentOffset: and setting the targetContentOffset I want but since I can't change the velocity it just scrolls too slowly or too fast.
  • I've tried implementing scrollViewDidEndDecelerating: and then animating to the correct offset but the scroll view first stops then moves, it doesn't look natural.
  • I've tried implementing scrollViewDidEndDragging:willDecelerate: and then animating to the correct offset but the scroll view "jumps" and does not animate correctly.

I'm out of ideas.

Thanks!

Update:

I ended up using Rob Mayoff's method, it looks clean. I changed it so it would work when the velocity is 0, for example when a user drags, stops and releases the finger.

- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView 
                     withVelocity:(CGPoint)velocity 
              targetContentOffset:(CGPoint *)targetContentOffset {
    CGFloat maxOffset = scrollView.contentSize.width - scrollView.bounds.size.width;
    CGFloat minOffset = 0;

    if (velocity.x == 0) {
        CGFloat targetX = MAX(minOffset,MIN(maxOffset, targetContentOffset->x));

        CGFloat diff = targetX - baseOffset;

        if (ABS(diff) > offsetStep/2) {
            if (diff > 0) {
                //going left
                baseOffset = MIN(maxOffset, baseOffset + offsetStep);
            } else {
                //going right
                baseOffset = MAX(minOffset, baseOffset - offsetStep);
            }
        }
    } else {
        if (velocity.x > 0) {
            baseOffset = MIN(maxOffset, baseOffset + offsetStep);
        } else {
            baseOffset = MAX(minOffset, baseOffset - offsetStep);
        }
    }

    targetContentOffset->x = baseOffset;
}

The only problem with this solution is that swiping the scroll view doesn't produce the "bounce" effect. It feels "stuck".

like image 357
fbernardo Avatar asked Apr 12 '13 00:04

fbernardo


2 Answers

Setting scrollView.decelerationRate = UIScrollViewDecelerationRateFast, combined with implementing scrollViewWillEndDragging:withVelocity:targetContentOffset:, seems to work for me using a collection view.

First, I give myself some instance variables:

@implementation ViewController {
    NSString *cellClassName;
    CGFloat baseOffset;
    CGFloat offsetStep;
}

In viewDidLoad, I set the view's decelerationRate:

- (void)viewDidLoad {
    [super viewDidLoad];
    cellClassName = NSStringFromClass([MyCell class]);
    [self.collectionView registerNib:[UINib nibWithNibName:cellClassName bundle:nil] forCellWithReuseIdentifier:cellClassName];
    self.collectionView.decelerationRate = UIScrollViewDecelerationRateFast;
}

I need offsetStep to be the size of an integral number of items that fit in the view's on-screen bounds. I compute it in viewDidLayoutSubviews:

- (void)viewDidLayoutSubviews {
    [super viewDidLayoutSubviews];
    UICollectionViewFlowLayout *layout = (UICollectionViewFlowLayout *)self.collectionView.collectionViewLayout;
    CGFloat stepUnit = layout.itemSize.width + layout.minimumLineSpacing;
    offsetStep = stepUnit * floorf(self.collectionView.bounds.size.width / stepUnit);
}

I need baseOffset to the be the X offset of the view before scrolling starts. I initialize it in viewDidAppear::

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    baseOffset = self.collectionView.contentOffset.x;
}

Then I need to force the view to scroll in steps of offsetStep. I do that in scrollViewWillEndDragging:withVelocity:targetContentOffset:. Depending on the velocity, I increase or decrease baseOffset by offsetStep. But I clamp baseOffset to a minimum of 0 and a maximum of the contentSize.width - bounds.size.width.

- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {
    if (velocity.x < 0) {
        baseOffset = MAX(0, baseOffset - offsetStep);
    } else if (velocity.x > 0) {
        baseOffset = MIN(scrollView.contentSize.width - scrollView.bounds.size.width, baseOffset + offsetStep);
    }
    targetContentOffset->x = baseOffset;
}

Note that I don't care what targetContentOffset->x comes in as.

This has the effect of aligning to the left edge of the leftmost visible item, until the user scrolls all the way to the last item. At that point it aligns to the right edge of the rightmost visible item, until the user scroll all the way to the left. This seems to match the behavior of the App Store app.

If that doesn't work for you, you can try replacing the last line (targetContentOffset->x = baseOffset) with this:

    dispatch_async(dispatch_get_main_queue(), ^{
        [scrollView setContentOffset:CGPointMake(baseOffset, 0) animated:YES];
    });

That also works for me.

You can find my test app in this git repository.

like image 151
rob mayoff Avatar answered Nov 10 '22 01:11

rob mayoff


You can either turn on pagingEnabled, or use decelerationRate = UIScrollViewDecelerationRateFast combined with scrollViewDidEndDragging and scrollViewDidEndDecelerating (which will minimize the effect of slowing scrolling to one location and then animating again to another location). It's probably not precisely what you want, but it's pretty close. And by using animateWithDuration it avoids the instantaneous jumping of that final snap.

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

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

You can then write a snapScrollView, such as:

- (void)snapScrollView:(UIScrollView *)scrollView
{
    CGPoint offset = scrollView.contentOffset;

    if ((offset.x + scrollView.frame.size.width) >= scrollView.contentSize.width)
    {
        // no snap needed ... we're at the end of the scrollview
        return;
    }

    // calculate where you want it to snap to

    offset.x = floorf(offset.x / kIconOffset + 0.5) * kIconOffset;

    // now snap it to there

    [UIView animateWithDuration:0.1
                     animations:^{
                         scrollView.contentOffset = offset;
                     }];
}
like image 25
Rob Avatar answered Nov 10 '22 01:11

Rob