84

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.

craft
  • 2,017
  • 1
  • 21
  • 30
Mark Bourke
  • 9,806
  • 7
  • 26
  • 30

17 Answers17

138

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

Mark Bourke
  • 9,806
  • 7
  • 26
  • 30
  • 3
    for some reason this results in my collection view being vertical and resizes all of my cells, any insights? – Jerland2 Jun 29 '17 at 02:30
  • 4
    For those who has `sectionInset.left` set, replace return statement by: `return CGPoint(x: proposedContentOffset.x + offsetAdjustment - sectionInset.left, y: proposedContentOffset.y)` – Andrey Gordeev Oct 09 '17 at 06:38
  • 1
    @AndreyGordeev you have to assign the scrollDirection to the layout instance, default is .vertical – Germán Jan 10 '18 at 16:44
  • @AndreyGordeev You're right, the solution doesn't respect section insets. It must be added manually to work properly. – Vahid Amiri Feb 01 '18 at 21:55
  • 1
    Hi, Mark Bourke I'm using your solution but if I swipe to fast sometimes it doesn't work (cutting first cell), I'm getting offset adjustment zero but it shouldn't be zero not able to find out how can I restrict user from fast scrolling ? PS: I'm using scroll in both directions (circular scrolling) – Iraniya Naynesh Mar 22 '18 at 05:33
  • For iOS 11's Safe Areas, might need to use `adjustedContentInset`. (e.g. `let horizontalOffset = proposedContentOffset.x + collectionView.adjustedContentInset.left`). I especially needed this because my collection view was vertical and inside a `UINavigationController`. – ABeard89 May 14 '18 at 05:30
  • 2
    This solution while it does snap cells, it's animation is very buggy as it doesn't respect velocity. If the user swipes very fast, you can see it glitch a lot. – NSPunk Aug 28 '18 at 05:01
  • 2
    I needed this to always snap to the next cell when swiping so I adjusted it a bit by checking the direction of the swipe: `layoutAttributesArray?.forEach({ (layoutAttributes) in let itemOffset = layoutAttributes.frame.origin.x let itemWidth = Float(layoutAttributes.frame.width) let direction: Float = velocity.x > 0 ? 1 : -1 if fabsf(Float(itemOffset - horizontalOffset)) < fabsf(Float(offsetAdjustment)) + itemWidth * direction { offsetAdjustment = itemOffset - horizontalOffset } })` – Leah Culver Oct 11 '18 at 02:03
  • 1
    This solution only works when user swipes with a big `velocity`, if not you will get annoying sticky effect. See my answer below for a better solution just by taking into account the `velocity` – Thanh-Nhon Nguyen Jun 08 '19 at 14:25
  • This solution is evil genious. Dropped it in and it worked ok. Added @LeahCulver's suggestion and it worked perfectly. Thanks you guys!!! – heyfrank Nov 13 '19 at 22:30
  • as @MarkBourke said at the bottom of his answer, this works good when adding **collectionView.decelerationRate = UIScrollView.DecelerationRate.fast** to the collectionView itself – Lance Samaria Jan 22 '20 at 20:12
  • Repeating what folks above have said, this solution ignores the velocity of the user's interaction. Because of that, there's a jarring snap back to centre if the user fails to swipe really hard. If your cells are small, you might not notice it, but even still, there's no way in this solution to customize what threshold will trigger the movement to the next cell. For those reasons, I recommend Nhon Nguyen's answer below instead. – Andrew Konoff May 20 '20 at 23:18
39

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()
    }
}
Vahid Amiri
  • 10,769
  • 13
  • 68
  • 113
  • This answer worked best for me. It was nice and smooth. I did make a change since I had cell spacing and didn't want it centered. Also wanted to control animation duration, so: `if closestCellIndex != -1 { UIView.animate(withDuration: 0.1) { let toX = (cellWidth + cellHorizontalSpacing) * CGFloat(closestCellIndex) scrollView.contentOffset = CGPoint(x: toX, y: 0) scrollView.layoutIfNeeded() } }` – Edan Apr 01 '19 at 22:24
  • @vahid-amiri Brilliant. Thanks. How in the world did learn this !! Long way to go for me :) – ashishn Apr 13 '19 at 08:36
  • Is it working for vertical scroll? horizontal scroll no issues – karthikeyan Oct 07 '19 at 08:50
  • This solution worked for me, as well, although you will want to disable ```Paging Enabled``` on the collectionView for which you are implementing this extension. When paging was enabled, the behavior was unpredictable. I believe this was because the automatic paging functionality was interfering with the manual calculation. – B-Rad Oct 30 '19 at 17:53
  • if i am displaying 3 cells per screen (1 at center half of the 2 cells right and left) after scrolling to the end the last cell is not displaying – Midhun Narayan Sep 22 '21 at 04:28
28

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)
  }
}
Richard Topchii
  • 7,075
  • 8
  • 48
  • 115
  • 1
    This is by far the best approach. It respects velocity even for small touches. For projects using `contentInset`, make sure to either *Add* or *Remove* it with `nearestPageOffset` var. – NSPunk Aug 28 '18 at 06:06
  • @NSPunk agree... There are a lot of popular apps with the similar design and they all have this weird glitch, when there is still velocity left, but not enough to snap to the next cell. – Richard Topchii Aug 29 '18 at 11:24
  • I had to add minimumLineSpacing to itemSpace to get it right. Nice work! – Nico S. Mar 23 '20 at 20:11
  • 1
    this does not properly reflect `itemSize`. this approach only gets `itemSize` set directly on `layout`, but if `itemSize` is set via `UICollectionViewDelegateFlowLayout`, then this newly set value is not recognized – gondo Apr 26 '20 at 14:46
  • @gondo any suggestions on how to improve this? – Richard Topchii Feb 24 '21 at 21:21
  • I scrapped using UICollectionViewDelegateFlowLayout, wrote a custom init function that took a cgSize and applied that to itemSize. – James Wolfe Apr 01 '21 at 11:17
25

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
}
Nam
  • 1,856
  • 2
  • 13
  • 18
21

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

Sourav Chandra
  • 843
  • 12
  • 21
  • 1
    You forgot to put your first code snippet inside a func called "snapToNearestCell(scrollView: UIScrollView)" – Starsky Jun 19 '19 at 09:52
19

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()
    }
}
buxik
  • 2,583
  • 24
  • 31
Mette
  • 1,166
  • 11
  • 12
  • 2
    This worked great for me. I would add that you get closer to the App Store "feel" if you set `collectionView.decelerationRate = UIScrollViewDecelerationRateFast` at some point. I'd also add that `FLT_MAX` on line 4 should be changed to `Float.greatestFiniteMagnitude`, to avoid Xcode warnings. – Chris Chute May 10 '17 at 18:42
17

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

}

Thanh-Nhon Nguyen
  • 3,402
  • 3
  • 28
  • 41
  • 1
    this one worked the best for me when adding **collectionView.decelerationRate = UIScrollView.DecelerationRate.fast** to the collectionView itself – Lance Samaria Jan 22 '20 at 20:02
  • 1
    I logged in merely to upvote this and say: this is the best solution on the page by far, and immediately worked exactly how I hoped. I spent far too long tweaking the top voted example. Thank you Nhon! – Andrew Konoff May 20 '20 at 23:15
  • Yup, this seems like it will do. – Winston Du Sep 29 '20 at 23:02
  • Only solution that worked for me going down from top to bottom. Others would glitch / not align cells to center. Works with fast deceleration super smooth! – Vlad Jan 23 '22 at 23:42
13

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.

NSDestr0yer
  • 1,419
  • 16
  • 20
  • This doesn't give the nicest feedback and also it doesn't work properly with collection views that have multiple rows but it sure is very easy to do. – Vahid Amiri Feb 01 '18 at 21:38
  • If you used this and your cells are not centered, make sure to set `minimumLineSpacing` to 0 for your layout – M Reza Oct 12 '20 at 09:33
4

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.

}
Community
  • 1
  • 1
Minebomber
  • 1,209
  • 2
  • 12
  • 35
  • Im working out the rough calculation stuff now, ill edit in about 10 mins with a basic working calculation. – Minebomber Nov 22 '15 at 16:07
  • If you're working with more than one collection view and you've set a viewController as its delegate, you can simply check the scrollView against the collectionView since the collectionView inherits from the scrollView. This is to find out which collectionView has stopped scrolling. – NYC Tech Engineer Dec 04 '15 at 19:34
  • @Minebomber Hey man, just tried this out, and I keep getting a bad access error when I try to get cellForItemAtIndexPath in the for loop. I was surprised an error was raised because I thought with a collectionView, you are always guaranteed a cell. – NYC Tech Engineer Dec 04 '15 at 19:45
  • Yeah, so explicitly calling delegate callbacks inside the actual delegate was a bad idea. – NYC Tech Engineer Dec 04 '15 at 19:57
  • So I ended up looping over the collectionView.visibleCells to get around my problem. I still ended up manually calling a delegate method to select the cell though... – NYC Tech Engineer Dec 04 '15 at 20:19
  • Sure.I just used a rough calculation method. – Minebomber Dec 05 '15 at 01:29
3

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];
    }
}
Iowa15
  • 3,027
  • 6
  • 28
  • 35
3

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;
mrcrowley
  • 101
  • 7
3

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.

Gonçalo Gaspar
  • 272
  • 1
  • 9
2

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

Roland Keesom
  • 8,180
  • 5
  • 45
  • 52
  • Hey Roland, how's life? Just ran into your answer to this question... Quite an oldy, but still nice, thanks! FYI, the `collectionView(mainCollectionView, numberOfItemsInSection:0)` method can only be used in this way if your object adopts the `UICollectionViewDataSource`. And why are you subtracting the `cellWidth` from `scrollView.contentSize.width`, isn't the total width just always the `scrollView.contentSize.width`? – Paul van Roosendaal Mar 29 '19 at 22:12
  • Hey Paul! In my situation subtracting the `cellWidth` was needed to offset it so that it is centered. Maybe in your case the `totalContentWidth` is calculated differently. – Roland Keesom Apr 18 '19 at 11:04
2

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.

Julius
  • 1,005
  • 1
  • 9
  • 19
1

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)
  }
nikwest
  • 105
  • 1
  • 6
  • Any idea why this changes my collection view cell size and makes my collection view that was set to horizontal, vertical? – Jerland2 Jun 29 '17 at 02:38
1

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

}
  • This has an unfortunate effect of `jumping to previous cell` when you scroll towards the left and immediately click (while scrolling animation is still happening). You will see that scroll got "cancelled" and jumped on previous cell. – gondo Apr 27 '20 at 07:57
0

In my case, I needed a single cell to snap to the center of a UICollectionView (like sliders in Photoshop Camera app).

I was able to achieve it using UIScrollViewDelegate methods and UICollectionViewFlowLayout.

This solution works even if your cells are of different widths and layout has spacing between items.

func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
    let targetXOffset = targetContentOffset.pointee.x + collectionView.frame.width / 2
    var xOffset = CGFloat.greatestFiniteMagnitude
    var targetIndex = 0
    
    for section in 0..<collectionView.numberOfSections {
        for item in 0..<collectionView.numberOfItems(inSection: section) {
            let indexPath = IndexPath(item: item, section: section)
            if let cellAttributes = layout.layoutAttributesForItem(at: indexPath) {
                let distance = abs(cellAttributes.frame.midX - targetXOffset)
                if distance < xOffset {
                    xOffset = distance
                    targetIndex = item
                }
            }
        }
    }
    
    let indexPath = IndexPath(item: targetIndex, section: 0)
    if let attributes = layout.layoutAttributesForItem(at: indexPath) {
        targetContentOffset.pointee = CGPoint(x: attributes.frame.midX - collectionView.frame.width / 2,
                                              y: targetContentOffset.pointee.y)
        // Most likely, you want the cell to be selected
        // collectionView.selectItem(at: indexPath, animated: true, scrollPosition: [])
    }
}
xinatanil
  • 1,085
  • 2
  • 13
  • 23