Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Snap to center of a cell when scrolling UICollectionView horizontally

I know some people have asked this question before but they were all about UITableViews or UIScrollViews and I couldn't get the accepted solution to work for me. What I would like is the snapping effect when scrolling through my UICollectionView horizontally - much like what happens in the iOS AppStore. iOS 9+ is my target build so please look at the UIKit changes before answering this.

Thanks.

like image 389
Mark Bourke Avatar asked Nov 22 '15 14:11

Mark Bourke


People also ask

How do I scroll horizontally in UICollectionView?

You need to reduce the height of UICollectionView to its cell / item height and select " Horizontal " from the " Scroll Direction " as seen in the screenshot below. Then it will scroll horizontally depending on the numberOfItems you have returned in its datasource implementation.

What is Uicollectionviewflowlayout?

A layout object that organizes items into a grid with optional header and footer views for each section.

Is CollectionView scrollable?

CollectionView defines two ScrollTo methods, that scroll items into view.


16 Answers

While originally I was using Objective-C, I since switched so Swift and the original accepted answer did not suffice.

I ended up creating a UICollectionViewLayout subclass which provides the best (imo) experience as opposed to the other functions which alter content offset or something similar when the user has stopped scrolling.

class SnappingCollectionViewLayout: UICollectionViewFlowLayout {

    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
        guard let collectionView = collectionView else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity) }

        var offsetAdjustment = CGFloat.greatestFiniteMagnitude
        let horizontalOffset = proposedContentOffset.x + collectionView.contentInset.left

        let targetRect = CGRect(x: proposedContentOffset.x, y: 0, width: collectionView.bounds.size.width, height: collectionView.bounds.size.height)

        let layoutAttributesArray = super.layoutAttributesForElements(in: targetRect)

        layoutAttributesArray?.forEach({ (layoutAttributes) in
            let itemOffset = layoutAttributes.frame.origin.x
            if fabsf(Float(itemOffset - horizontalOffset)) < fabsf(Float(offsetAdjustment)) {
                offsetAdjustment = itemOffset - horizontalOffset
            }
        })

        return CGPoint(x: proposedContentOffset.x + offsetAdjustment, y: proposedContentOffset.y)
    }
}

For the most native feeling deceleration with the current layout subclass, make sure to set the following:

collectionView?.decelerationRate = UIScrollViewDecelerationRateFast

like image 149
Mark Bourke Avatar answered Oct 06 '22 01:10

Mark Bourke


Based on answer from Mete and comment from Chris Chute,

Here's a Swift 4 extension that will do just what OP wants. It's tested on single row and double row nested collection views and it works just fine.

extension UICollectionView {
    func scrollToNearestVisibleCollectionViewCell() {
        self.decelerationRate = UIScrollViewDecelerationRateFast
        let visibleCenterPositionOfScrollView = Float(self.contentOffset.x + (self.bounds.size.width / 2))
        var closestCellIndex = -1
        var closestDistance: Float = .greatestFiniteMagnitude
        for i in 0..<self.visibleCells.count {
            let cell = self.visibleCells[i]
            let cellWidth = cell.bounds.size.width
            let cellCenter = Float(cell.frame.origin.x + cellWidth / 2)

            // Now calculate closest cell
            let distance: Float = fabsf(visibleCenterPositionOfScrollView - cellCenter)
            if distance < closestDistance {
                closestDistance = distance
                closestCellIndex = self.indexPath(for: cell)!.row
            }
        }
        if closestCellIndex != -1 {
            self.scrollToItem(at: IndexPath(row: closestCellIndex, section: 0), at: .centeredHorizontally, animated: true)
        }
    }
}

You need to implement UIScrollViewDelegate protocol for your collection view and then add these two methods:

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    self.collectionView.scrollToNearestVisibleCollectionViewCell()
}

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    if !decelerate {
        self.collectionView.scrollToNearestVisibleCollectionViewCell()
    }
}
like image 23
Vahid Amiri Avatar answered Oct 06 '22 01:10

Vahid Amiri


Snap to the nearest cell, respecting scroll velocity.

Works without any glitches.

import UIKit

final class SnapCenterLayout: UICollectionViewFlowLayout {
  override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
    guard let collectionView = collectionView else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity) }
    let parent = super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)

    let itemSpace = itemSize.width + minimumInteritemSpacing
    var currentItemIdx = round(collectionView.contentOffset.x / itemSpace)

    // Skip to the next cell, if there is residual scrolling velocity left.
    // This helps to prevent glitches
    let vX = velocity.x
    if vX > 0 {
      currentItemIdx += 1
    } else if vX < 0 {
      currentItemIdx -= 1
    }

    let nearestPageOffset = currentItemIdx * itemSpace
    return CGPoint(x: nearestPageOffset,
                   y: parent.y)
  }
}
like image 25
Richard Topchii Avatar answered Oct 06 '22 02:10

Richard Topchii


For what it is worth here is a simple calculation that I use (in swift):

func snapToNearestCell(_ collectionView: UICollectionView) {
    for i in 0..<collectionView.numberOfItems(inSection: 0) {

        let itemWithSpaceWidth = collectionViewFlowLayout.itemSize.width + collectionViewFlowLayout.minimumLineSpacing
        let itemWidth = collectionViewFlowLayout.itemSize.width

        if collectionView.contentOffset.x <= CGFloat(i) * itemWithSpaceWidth + itemWidth / 2 {                
            let indexPath = IndexPath(item: i, section: 0)
            collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
            break
        }
    }
}

Call where you need it. I call it in

func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    snapToNearestCell(scrollView)
}

And

func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
    snapToNearestCell(scrollView)
}

Where collectionViewFlowLayout could come from:

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()

    // Set up collection view
    collectionViewFlowLayout = collectionView.collectionViewLayout as! UICollectionViewFlowLayout
}
like image 30
Nam Avatar answered Oct 06 '22 01:10

Nam


SWIFT 3 version of @Iowa15 reply

func scrollToNearestVisibleCollectionViewCell() {
    let visibleCenterPositionOfScrollView = Float(collectionView.contentOffset.x + (self.collectionView!.bounds.size.width / 2))
    var closestCellIndex = -1
    var closestDistance: Float = .greatestFiniteMagnitude
    for i in 0..<collectionView.visibleCells.count {
        let cell = collectionView.visibleCells[i]
        let cellWidth = cell.bounds.size.width
        let cellCenter = Float(cell.frame.origin.x + cellWidth / 2)

        // Now calculate closest cell
        let distance: Float = fabsf(visibleCenterPositionOfScrollView - cellCenter)
        if distance < closestDistance {
            closestDistance = distance
            closestCellIndex = collectionView.indexPath(for: cell)!.row
        }
    }
    if closestCellIndex != -1 {
        self.collectionView!.scrollToItem(at: IndexPath(row: closestCellIndex, section: 0), at: .centeredHorizontally, animated: true)
    }
}

Needs to implement in UIScrollViewDelegate:

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    scrollToNearestVisibleCollectionViewCell()
}

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    if !decelerate {
        scrollToNearestVisibleCollectionViewCell()
    }
}
like image 41
Mette Avatar answered Oct 06 '22 01:10

Mette


Here is my implementation

func snapToNearestCell(scrollView: UIScrollView) {
     let middlePoint = Int(scrollView.contentOffset.x + UIScreen.main.bounds.width / 2)
     if let indexPath = self.cvCollectionView.indexPathForItem(at: CGPoint(x: middlePoint, y: 0)) {
          self.cvCollectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
     }
}

Implement your scroll view delegates like this

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    self.snapToNearestCell(scrollView: scrollView)
}

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    self.snapToNearestCell(scrollView: scrollView)
}

Also, for better snapping

self.cvCollectionView.decelerationRate = UIScrollViewDecelerationRateFast

Works like a charm

like image 22
Sourav Chandra Avatar answered Oct 06 '22 00:10

Sourav Chandra


I tried both @Mark Bourke and @mrcrowley solutions but they give the pretty same results with unwanted sticky effects.

I managed to solve the problem by taking into account the velocity. Here is the full code.

final class BetterSnappingLayout: UICollectionViewFlowLayout {
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
    guard let collectionView = collectionView else {
        return super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)
    }

    var offsetAdjusment = CGFloat.greatestFiniteMagnitude
    let horizontalCenter = proposedContentOffset.x + (collectionView.bounds.width / 2)
    
    let targetRect = CGRect(x: proposedContentOffset.x, y: 0, width: collectionView.bounds.size.width, height: collectionView.bounds.size.height)
    let layoutAttributesArray = super.layoutAttributesForElements(in: targetRect)
    layoutAttributesArray?.forEach({ (layoutAttributes) in
        let itemHorizontalCenter = layoutAttributes.center.x
        
        if abs(itemHorizontalCenter - horizontalCenter) < abs(offsetAdjusment) {
            if abs(velocity.x) < 0.3 { // minimum velocityX to trigger the snapping effect
                offsetAdjusment = itemHorizontalCenter - horizontalCenter
            } else if velocity.x > 0 {
                offsetAdjusment = itemHorizontalCenter - horizontalCenter + layoutAttributes.bounds.width
            } else { // velocity.x < 0
                offsetAdjusment = itemHorizontalCenter - horizontalCenter - layoutAttributes.bounds.width
            }
        }
    })

    return CGPoint(x: proposedContentOffset.x + offsetAdjusment, y: proposedContentOffset.y)
}

}

like image 44
Thanh-Nhon Nguyen Avatar answered Oct 06 '22 01:10

Thanh-Nhon Nguyen


If you want simple native behavior, without customization:

collectionView.pagingEnabled = YES;

This only works properly when the size of the collection view layout items are all one size only and the UICollectionViewCell's clipToBounds property is set to YES.

like image 41
NSDestr0yer Avatar answered Oct 06 '22 01:10

NSDestr0yer


Got an answer from SO post here and docs here

First What you can do is set your collection view's scrollview's delegate your class by making your class a scrollview delegate

MyViewController : SuperViewController<... ,UIScrollViewDelegate>

Then make set your view controller as the delegate

UIScrollView *scrollView = (UIScrollView *)super.self.collectionView;
scrollView.delegate = self;

Or do it in the interface builder by control + shift clicking on your collection view and then control + drag or right click drag to your view controller and select delegate. (You should know how to do this). That doesn't work. UICollectionView is a subclass of UIScrollView so you will now be able to see it in the interface builder by control + shift clicking

Next implement the delegate method - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView

MyViewController.m

... 

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
{

}

The docs state that:

Parameters

scrollView | The scroll-view object that is decelerating the scrolling of the content view.

Discussion The scroll view calls this method when the scrolling movement comes to a halt. The decelerating property of UIScrollView controls deceleration.

Availability Available in iOS 2.0 and later.

Then inside of that method check which cell was closest to the center of the scrollview when it stopped scrolling

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
{
  //NSLog(@"%f", truncf(scrollView.contentOffset.x + (self.pictureCollectionView.bounds.size.width / 2)));

float visibleCenterPositionOfScrollView = scrollView.contentOffset.x + (self.pictureCollectionView.bounds.size.width / 2);

//NSLog(@"%f", truncf(visibleCenterPositionOfScrollView / imageArray.count));


NSInteger closestCellIndex;

for (id item in imageArray) {
    // equation to use to figure out closest cell
    // abs(visibleCenter - cellCenterX) <= (cellWidth + cellSpacing/2)

    // Get cell width (and cell too)
    UICollectionViewCell *cell = (UICollectionViewCell *)[self collectionView:self.pictureCollectionView cellForItemAtIndexPath:[NSIndexPath indexPathWithIndex:[imageArray indexOfObject:item]]];
    float cellWidth = cell.bounds.size.width;

    float cellCenter = cell.frame.origin.x + cellWidth / 2;

    float cellSpacing = [self collectionView:self.pictureCollectionView layout:self.pictureCollectionView.collectionViewLayout minimumInteritemSpacingForSectionAtIndex:[imageArray indexOfObject:item]];

    // Now calculate closest cell

    if (fabsf(visibleCenterPositionOfScrollView - cellCenter) <= (cellWidth + (cellSpacing / 2))) {
        closestCellIndex = [imageArray indexOfObject:item];
        break;
    }
}

if (closestCellIndex != nil) {

[self.pictureCollectionView scrollToItemAtIndexPath:[NSIndexPath indexPathWithIndex:closestCellIndex] atScrollPosition:UICollectionViewScrollPositionCenteredVertically animated:YES];

// This code is untested. Might not work.

}
like image 28
Minebomber Avatar answered Oct 06 '22 02:10

Minebomber


A modification of the above answer which you can also try:

-(void)scrollToNearestVisibleCollectionViewCell {
    float visibleCenterPositionOfScrollView = _collectionView.contentOffset.x + (self.collectionView.bounds.size.width / 2);

    NSInteger closestCellIndex = -1;
    float closestDistance = FLT_MAX;
    for (int i = 0; i < _collectionView.visibleCells.count; i++) {
        UICollectionViewCell *cell = _collectionView.visibleCells[i];
        float cellWidth = cell.bounds.size.width;

        float cellCenter = cell.frame.origin.x + cellWidth / 2;

        // Now calculate closest cell
        float distance = fabsf(visibleCenterPositionOfScrollView - cellCenter);
        if (distance < closestDistance) {
            closestDistance = distance;
            closestCellIndex = [_collectionView indexPathForCell:cell].row;
        }
    }

    if (closestCellIndex != -1) {
        [self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForRow:closestCellIndex inSection:0] atScrollPosition:UICollectionViewScrollPositionCenteredHorizontally animated:YES];
    }
}
like image 38
Iowa15 Avatar answered Oct 06 '22 00:10

Iowa15


I just found what I think is the best possible solution to this problem:

First add a target to the collectionView's already existing gestureRecognizer:

[self.collectionView.panGestureRecognizer addTarget:self action:@selector(onPan:)];

Have the selector point to a method which takes a UIPanGestureRecognizer as a parameter:

- (void)onPan:(UIPanGestureRecognizer *)recognizer {};

Then in this method, force the collectionView to scroll to the appropriate cell when the pan gesture has ended. I did this by getting the visible items from the collection view and determining which item I want to scroll to depending on the direction of the pan.

if (recognizer.state == UIGestureRecognizerStateEnded) {

        // Get the visible items
        NSArray<NSIndexPath *> *indexes = [self.collectionView indexPathsForVisibleItems];
        int index = 0;

        if ([(UIPanGestureRecognizer *)recognizer velocityInView:self.view].x > 0) {
            // Return the smallest index if the user is swiping right
            for (int i = index;i < indexes.count;i++) {
                if (indexes[i].row < indexes[index].row) {
                    index = i;
                }
            }
        } else {
            // Return the biggest index if the user is swiping left
            for (int i = index;i < indexes.count;i++) {
                if (indexes[i].row > indexes[index].row) {
                    index = i;
                }
            }
        }
        // Scroll to the selected item
        [self.collectionView scrollToItemAtIndexPath:indexes[index] atScrollPosition:UICollectionViewScrollPositionLeft animated:YES];
    }

Keep in mind that in my case only two items can be visible at a time. I'm sure this method can be adapted for more items however.

like image 27
Julius Avatar answered Oct 06 '22 00:10

Julius


This from a 2012 WWDC video for an Objective-C solution. I subclassed UICollectionViewFlowLayout and added the following.

-(CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity
    {
        CGFloat offsetAdjustment = MAXFLOAT;
        CGFloat horizontalCenter = proposedContentOffset.x + (CGRectGetWidth(self.collectionView.bounds) / 2);

        CGRect targetRect = CGRectMake(proposedContentOffset.x, 0.0, self.collectionView.bounds.size.width, self.collectionView.bounds.size.height);
        NSArray *array = [super layoutAttributesForElementsInRect:targetRect];

        for (UICollectionViewLayoutAttributes *layoutAttributes in array)
        {
            CGFloat itemHorizontalCenter = layoutAttributes.center.x;
            if (ABS(itemHorizontalCenter - horizontalCenter) < ABS(offsetAdjustment))
            {
                offsetAdjustment = itemHorizontalCenter - horizontalCenter;
            }
        }

        return CGPointMake(proposedContentOffset.x + offsetAdjustment, proposedContentOffset.y);
    }

And the reason I got to this question was for the snapping with a native feel, which I got from Mark's accepted answer... this I put in the collectionView's view controller.

collectionView.decelerationRate = UIScrollViewDecelerationRateFast;
like image 32
mrcrowley Avatar answered Oct 06 '22 01:10

mrcrowley


This solution gives a better and smoother animation.

Swift 3

To get the first and last item to center add insets:

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {

    return UIEdgeInsetsMake(0, cellWidth/2, 0, cellWidth/2)
}

Then use the targetContentOffset in the scrollViewWillEndDragging method to alter the ending position.

func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {

    let numOfItems = collectionView(mainCollectionView, numberOfItemsInSection:0)
    let totalContentWidth = scrollView.contentSize.width + mainCollectionViewFlowLayout.minimumInteritemSpacing - cellWidth
    let stopOver = totalContentWidth / CGFloat(numOfItems)

    var targetX = round((scrollView.contentOffset.x + (velocity.x * 300)) / stopOver) * stopOver
    targetX = max(0, min(targetX, scrollView.contentSize.width - scrollView.frame.width))

    targetContentOffset.pointee.x = targetX
}

Maybe in your case the totalContentWidth is calculated differently, f.e. without a minimumInteritemSpacing, so adjust that accordingly. Also you can play around with the 300 used in the velocity

P.S. Make sure the class adopts the UICollectionViewDataSource protocol

like image 23
Roland Keesom Avatar answered Oct 06 '22 02:10

Roland Keesom


I've been solving this issue by setting 'Paging Enabled' on the attributes inspector on the uicollectionview.

For me this happens when the width of the cell is the same as the width of the uicollectionview.

No coding involved.

like image 20
Gonçalo Gaspar Avatar answered Oct 06 '22 00:10

Gonçalo Gaspar


Here is a Swift 3.0 version, which should work for both horizontal and vertical directions based on Mark's suggestion above:

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

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

    let realOffset = CGPoint(
      x: proposedContentOffset.x + collectionView.contentInset.left,
      y: proposedContentOffset.y + collectionView.contentInset.top
    )

    let targetRect = CGRect(origin: proposedContentOffset, size: collectionView.bounds.size)

    var offset = (scrollDirection == .horizontal)
      ? CGPoint(x: CGFloat.greatestFiniteMagnitude, y:0.0)
      : CGPoint(x:0.0, y:CGFloat.greatestFiniteMagnitude)

    offset = self.layoutAttributesForElements(in: targetRect)?.reduce(offset) {
      (offset, attr) in
      let itemOffset = attr.frame.origin
      return CGPoint(
        x: abs(itemOffset.x - realOffset.x) < abs(offset.x) ? itemOffset.x - realOffset.x : offset.x,
        y: abs(itemOffset.y - realOffset.y) < abs(offset.y) ? itemOffset.y - realOffset.y : offset.y
      )
    } ?? .zero

    return CGPoint(x: proposedContentOffset.x + offset.x, y: proposedContentOffset.y + offset.y)
  }
like image 40
nikwest Avatar answered Oct 06 '22 00:10

nikwest


Swift 4.2. Simple. For fixed itemSize. Horizontal flow direction.

func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {

    if let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout {
        let floatingPage = targetContentOffset.pointee.x/scrollView.bounds.width
        let rule: FloatingPointRoundingRule = velocity.x > 0 ? .up : .down
        let page = CGFloat(Int(floatingPage.rounded(rule)))
        targetContentOffset.pointee.x = page*(layout.itemSize.width + layout.minimumLineSpacing)
    }

}
like image 20
Javier Glez. Avatar answered Oct 06 '22 00:10

Javier Glez.