26

I have a Collection View that can show about 3.5 cells at a time, and I want it to be paging-enabled. But I'd like it to snap to each cell (just like the App Store app does), and not scroll the full width of the view. How can I do that?

Guilherme
  • 7,839
  • 9
  • 56
  • 99

6 Answers6

51

Another way is to create a custom UICollectionViewFlowLayout and override the method like so:

- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)offset 
                                 withScrollingVelocity:(CGPoint)velocity {

    CGRect cvBounds = self.collectionView.bounds;
    CGFloat halfWidth = cvBounds.size.width * 0.5f;
    CGFloat proposedContentOffsetCenterX = offset.x + halfWidth;

    NSArray* attributesArray = [self layoutAttributesForElementsInRect:cvBounds];

    UICollectionViewLayoutAttributes* candidateAttributes;
    for (UICollectionViewLayoutAttributes* attributes in attributesArray) {

        // == Skip comparison with non-cell items (headers and footers) == //
        if (attributes.representedElementCategory != 
            UICollectionElementCategoryCell) {
            continue;
        }

        // == First time in the loop == //
        if(!candidateAttributes) {
            candidateAttributes = attributes;
            continue;
        }

        if (fabsf(attributes.center.x - proposedContentOffsetCenterX) < 
            fabsf(candidateAttributes.center.x - proposedContentOffsetCenterX)) {
            candidateAttributes = attributes;
        }
    }

    return CGPointMake(candidateAttributes.center.x - halfWidth, offset.y);

}

If you are looking for a Swift solution, check out this Gist

  • note: this will work when we are really showing preview cells (even if they have an alpha of 0.0f). This is because if the preview cells are not available at the time of scrolling their attributes object will not be passed in the loop...
Mike M
  • 4,879
  • 5
  • 38
  • 58
  • Generally, you should sketch your solution in the body of your answer rather than just including a link so that if the link breaks, the answer is still helpful. – Jesse Rusak Mar 28 '14 at 13:24
  • 1
    This is the ONLY solution I could find for successfully implementing vertical paging by cell in a UICollectionView. Obviously you have to modify the code to use y values instead of x values, but it works perfectly and feels just like normal pagingEnabled. Thanks Mike! – user3344977 Apr 11 '15 at 21:32
  • 1
    @MikeM - awesome piece of code man! tested at least a dozen of these, which all failed. The only problem occurs when a user flicks quickly, which will cause the nearest cell to be skipped. Any idea how to fix this? Thanks! – trdavidson Jun 02 '15 at 08:56
  • @trdavidson Did you ever figure out how to get it to not skip the nearest cell? I'm having a similar problem – Thomas Aug 09 '15 at 23:53
  • I thought that this is what I was doing ;-). Can you please clarify what you are asking? – Mike M Aug 10 '15 at 09:41
  • 1
    @Thomas - I did, simply setting [yourScrollView].decelerationRate = UIScrollViewDecelerationRateFast did the trick for me! – trdavidson Aug 10 '15 at 15:36
  • @MikeM the problem I'm facing is that if I give the collection view a light flick to the left or right, it moves two cells over, instead of one. Instead I want it to move one cell over almost always, unless they give it a really hard flick, in which it should move 2 cells over. The reason I'm encountering this problem is because my cells width are 1/3 of the visible screen. – Thomas Aug 10 '15 at 18:28
  • 1
    @trdavidson I incorporated your solution in my blog post and quoted your name. Thanks! – Mike M Aug 10 '15 at 19:00
19

Here's my implementation in Swift 5 for vertical cell-based paging:

override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {

    guard let collectionView = self.collectionView else {
        let latestOffset = super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)
        return latestOffset
    }

    // Page height used for estimating and calculating paging.
    let pageHeight = self.itemSize.height + self.minimumLineSpacing

    // Make an estimation of the current page position.
    let approximatePage = collectionView.contentOffset.y/pageHeight

    // Determine the current page based on velocity.
    let currentPage = velocity.y == 0 ? round(approximatePage) : (velocity.y < 0.0 ? floor(approximatePage) : ceil(approximatePage))

    // Create custom flickVelocity.
    let flickVelocity = velocity.y * 0.3

    // Check how many pages the user flicked, if <= 1 then flickedPages should return 0.
    let flickedPages = (abs(round(flickVelocity)) <= 1) ? 0 : round(flickVelocity)

    let newVerticalOffset = ((currentPage + flickedPages) * pageHeight) - collectionView.contentInset.top

    return CGPoint(x: proposedContentOffset.x, y: newVerticalOffset)
}

Some notes:

  • Doesn't glitch
  • SET PAGING TO FALSE! (otherwise this won't work)
  • Allows you to set your own flickvelocity easily.
  • If something is still not working after trying this, check if your itemSize actually matches the size of the item as that's often a problem, especially when using collectionView(_:layout:sizeForItemAt:), use a custom variable with the itemSize instead.
  • This works best when you set self.collectionView.decelerationRate = UIScrollView.DecelerationRate.fast.

Here's a horizontal version (haven't tested it thoroughly so please forgive any mistakes):

override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {

    guard let collectionView = self.collectionView else {
        let latestOffset = super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)
        return latestOffset
    }

    // Page width used for estimating and calculating paging.
    let pageWidth = self.itemSize.width + self.minimumInteritemSpacing

    // Make an estimation of the current page position.
    let approximatePage = collectionView.contentOffset.x/pageWidth

    // Determine the current page based on velocity.
    let currentPage = velocity.x == 0 ? round(approximatePage) : (velocity.x < 0.0 ? floor(approximatePage) : ceil(approximatePage))

    // Create custom flickVelocity.
    let flickVelocity = velocity.x * 0.3

    // Check how many pages the user flicked, if <= 1 then flickedPages should return 0.
    let flickedPages = (abs(round(flickVelocity)) <= 1) ? 0 : round(flickVelocity)

    // Calculate newHorizontalOffset.
    let newHorizontalOffset = ((currentPage + flickedPages) * pageWidth) - collectionView.contentInset.left

    return CGPoint(x: newHorizontalOffset, y: proposedContentOffset.y)
}

This code is based on the code I use in my personal project, you can check it out here by downloading it and running the Example target.

JoniVR
  • 1,839
  • 1
  • 22
  • 36
  • 2
    Thank you! Yours was the only solution that worked properly! I had to replace itemSize with a custom calculation, because itemSize is only used if you are not using collectionView(_:layout:sizeForItemAt:) as per https://developer.apple.com/documentation/uikit/uicollectionviewflowlayout/1617711-itemsize – Zoltán Matók Oct 18 '18 at 19:07
  • 2
    Since people are probably using collectionView(_:layout:sizeForItemAt:) when using this method, it might be better to replace the use of itemSize with something else. – Zoltán Matók Oct 18 '18 at 19:09
  • 1
    I would upvote this a lot of times if I could, I've been struggling this for so long and none of the other anwers worked for me. – Zoltán Matók Oct 18 '18 at 19:20
  • 1
    Great solution! Though the horizontally-scrolling answer should be using self.minimumLineSpacing as well instead of self.minimumInteritemSpacing. Also combined with this answer and flipping those minimum values, I got better-feeling scrolling results by setting self.collectionView.decelerationRate = UIScrollViewDecelerationRateFast. – Dafurman Dec 29 '18 at 03:06
  • Yes, this is something I forgot to mention too! This works best with self.collectionView.decelerationRate = UIScrollViewDecelerationRateFast. I'll update the answer to reflect it for further people that stumble upon this! – JoniVR Dec 29 '18 at 15:18
  • where put this function? – Sajjad Apr 10 '19 at 12:17
  • @Sajjad inside a subclassed `UICollectionViewFlowLayout` :) – JoniVR Apr 10 '19 at 19:36
  • @JoniVR i set it. but when i scroll fast, it skip 1 or 2 cell and snap to 3rd cell. how can i handle that to snap exactly 1 next or prev cell? – Sajjad Apr 13 '19 at 04:13
  • @Sajjad Hmmm that's a feature of this algorithm though, try taking out the `let flickvelocity` and `let flickedPages` and then change `((currentPage + flickedPages) * pageWidth) - self.collectionView!.contentInset.left` to `(currentPage * pageWidth) - self.collectionView!.contentInset.left`. I haven't tested this properly but if it doesn't work you should try changing it up by taking the `flickedPages` out of the equation. – JoniVR Apr 13 '19 at 09:27
  • 1
    Found a small issue when releasing the drag with no velocity- basically, the collection view will snap to the nearest next item. Replacing `let currentPage = (velocity.x < 0.0) ? floor(approximatePage) : ceil(approximatePage)` with `let currentPage = velocity.x == 0 ? round(approximatePage) : (velocity.x < 0.0 ? floor(approximatePage) : ceil(approximatePage))` seems to produce the expected snapping to the nearest item. (NOTE: This is for the horizontal version. The vertical equivalent should be trivial to work out) – bcl Aug 15 '19 at 12:39
  • @JoniVR I had the same issue as **Sajjad** I tried your suggestion in the comments but it still skips over 1 & 2 and goes straight to 3. I took `flickedPages` out of the equation but it still does that same thing. Any ideas? – Lance Samaria Jun 24 '21 at 03:32
18

You can snap to cells by being the delegate of the collection view and implementing the method:

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

This tells you that the user has finished a drag and it allows you to modify the targetContentOffset to align with your cells (i.e. round to the nearest cell). Note that you need to be careful about how you modify the targetContentOffset; in particular, you need to avoid changing it so that the view needs to scroll in the opposite direction of the passed velocity, or you'll get animation glitches. You can probably find many examples of this if you google for that method name.

Jesse Rusak
  • 56,530
  • 12
  • 101
  • 102
  • I think that the method above does not allow you to change the targetContentOffset... – Mike M Mar 27 '14 at 18:20
  • 3
    @MikeM Yes, that's why it's an inout parameter; you can use something like `*targetContentOffset = CGPointMake(...)`. – Jesse Rusak Mar 27 '14 at 21:18
  • @JesseRusak - I really like this idea, however when I try to implement it, the collectionview keeps scrolling on a fast scroll. Any suggestions on how to stop this? Thanks! – trdavidson Jun 02 '15 at 08:52
  • @trdavidson I'm not sure; it depends on how you implement the method. Maybe post a new question and link it here? – Jesse Rusak Jun 02 '15 at 12:04
  • @JesseRusak I ended up using MikeM his custom FlowLayout implementation as it works quite nicely. Thanks for getting back, I might fiddle around with this one as well later on to see if I can make it work - cheers – trdavidson Jun 02 '15 at 18:11
4

I developed my solution before looking at the ones here. I also went with creating a custom UICollectionViewFlowLayout and override the targetContentOffset method.

It seems to work fine for me (i.e. I get the same behavior as in the AppStore) even though I got much less code. Here it is, feel free to point me any drawback you can think of:

override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {

    let inset: Int = 10
    let vcBounds = self.collectionView!.bounds
    var candidateContentOffsetX: CGFloat = proposedContentOffset.x

    for attributes in self.layoutAttributesForElements(in: vcBounds)! as [UICollectionViewLayoutAttributes] {

        if vcBounds.origin.x < attributes.center.x {
            candidateContentOffsetX = attributes.frame.origin.x - CGFloat(inset)
            break
        }

    }

    return CGPoint(x: candidateContentOffsetX, y: proposedContentOffset.y)
}
Filip Juncu
  • 352
  • 2
  • 10
  • I couldn't compile because `self.layoutAttributesForElements` missing in my view controller. – Boris Y. Dec 15 '17 at 12:29
  • Spent 2 days trying to get a solution and some worked almost perfectly but ultimately weren't good enough. I took this and tweaked it and it ended up perfect. Thank you – user1898712 May 03 '19 at 13:20
0

The solution Mike M. presented in the post before worked for me but in my case I wanted to have the first cell starting in the middle of the collectionView. So I used the collection flow delegate method to defined an inset (collectionView:layout:insetForSectionAtIndex:). This made the scroll between the first cell and second to be stuck and not scroll correctly to the first cell.

The reason for this was that candidateAttributes.center.x - halfWidth was having a negative value. The solution was to get the absolute value so I add fabs to this line return CGPointMake(fabs(candidateAttributes.center.x - halfWidth), offset.y);

Fabs should be added by default to cover all situations.

lopes710
  • 315
  • 1
  • 3
  • 19
0

This is my solution. Works with any page width.

Set self.collectionView.decelerationRate = UIScrollViewDecelerationRateFast to feel a real paging.

The solution is based on one section to scroll paginated by items.

- (CGFloat)pageWidth {

    return self.itemSize.width + self.minimumLineSpacing;
}

- (CGPoint)offsetAtCurrentPage {

    CGFloat width = -self.collectionView.contentInset.left - self.sectionInset.left;
    for (int i = 0; i < self.currentPage; i++)
        width += [self pageWidth];

    return CGPointMake(width, 0);
}

- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset {

    return [self offsetAtCurrentPage];
}

- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity {

    // To scroll paginated
    /*
    if (velocity.x > 0 && self.currentPage < [self.collectionView numberOfItemsInSection:0]-1) self.currentPage += 1;
    else if (velocity.x < 0 && self.currentPage > 0) self.currentPage -= 1;

    return  [self offsetAtCurrentPage];
    */

    // To scroll and stop always at the center of a page
    CGRect proposedRect = CGRectMake(proposedContentOffset.x+self.collectionView.bounds.size.width/2 - self.pageWidth/2, 0, self.pageWidth, self.collectionView.bounds.size.height);
    NSMutableArray <__kindof UICollectionViewLayoutAttributes *> *allAttributes = [[self layoutAttributesForElementsInRect:proposedRect] mutableCopy];
    __block UICollectionViewLayoutAttributes *proposedAttributes = nil;
    __block CGFloat minDistance = CGFLOAT_MAX;
    [allAttributes enumerateObjectsUsingBlock:^(__kindof UICollectionViewLayoutAttributes * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {

        CGFloat distance = CGRectGetMidX(proposedRect) - obj.center.x;

        if (ABS(distance) < minDistance) {
            proposedAttributes = obj;
            minDistance = distance;
        }
    }];


    // Scroll always
    if (self.currentPage == proposedAttributes.indexPath.row) {
        if (velocity.x > 0 && self.currentPage < [self.collectionView numberOfItemsInSection:0]-1) self.currentPage += 1;
        else if (velocity.x < 0 && self.currentPage > 0) self.currentPage -= 1;
    }
    else  {
        self.currentPage = proposedAttributes.indexPath.row;
    }

    return  [self offsetAtCurrentPage];
}

This is paginated by sections.

- (CGPoint)offsetAtCurrentPage {

    CGFloat width = -self.collectionView.contentInset.leff;
    for (int i = 0; i < self.currentPage; i++)
        width += [self sectionWidth:i];
    return CGPointMake(width, 0);
}

- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset
{
    return [self offsetAtCurrentPage];
}

- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity {

    // To scroll paginated
    /*
    if (velocity.x > 0 && self.currentPage < [self.collectionView numberOfSections]-1) self.currentPage += 1;
    else if (velocity.x < 0 && self.currentPage > 0) self.currentPage -= 1;

    return  [self offsetAtCurrentPage];
    */

    // To scroll and stop always at the center of a page
    CGRect proposedRect = CGRectMake(proposedContentOffset.x+self.collectionView.bounds.size.width/2 - [self sectionWidth:0]/2, 0, [self sectionWidth:0], self.collectionView.bounds.size.height);
    NSMutableArray <__kindof UICollectionViewLayoutAttributes *> *allAttributes = [[self layoutAttributesForElementsInRect:proposedRect] mutableCopy];
    __block UICollectionViewLayoutAttributes *proposedAttributes = nil;
    __block CGFloat minDistance = CGFLOAT_MAX;
    [allAttributes enumerateObjectsUsingBlock:^(__kindof UICollectionViewLayoutAttributes * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {

        CGFloat distance = CGRectGetMidX(proposedRect) - obj.center.x;

        if (ABS(distance) < minDistance) {
            proposedAttributes = obj;
            minDistance = distance;
        }
    }];

    // Scroll always
    if (self.currentPage == proposedAttributes.indexPath.section) {
        if (velocity.x > 0 && self.currentPage < [self.collectionView numberOfSections]-1) self.currentPage += 1;
        else if (velocity.x < 0 && self.currentPage > 0) self.currentPage -= 1;
    }
    else  {
        self.currentPage = proposedAttributes.indexPath.section;
    }

    return  [self offsetAtCurrentPage];
}
Javi
  • 1
  • 2