25
votes

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?

6

6 Answers

18
votes

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.

51
votes

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...
18
votes

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.

4
votes

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)
}
0
votes

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.

0
votes

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];
}