102

I've got a very simple collectionView in my app (just a single row of square thumbnail images).

I'd like to intercept the scrolling so that the offset always leaves a full image at the left side. At the moment it scrolls to wherever and will leave cut off images.

Anyway, I know I need to use the function

- (CGPoint)targetContentOffsetForProposedContentOffset:withScrollingVelocity

to do this but I'm just using a standard UICollectionViewFlowLayout. I'm not subclassing it.

Is there any way of intercepting this without subclassing UICollectionViewFlowLayout?

Thanks

Fogmeister
  • 70,181
  • 37
  • 189
  • 274

18 Answers18

117

OK, answer is no, there is no way to do this without subclassing UICollectionViewFlowLayout.

However, subclassing it is incredibly easy for anyone who is reading this in the future.

First I set up the subclass call MyCollectionViewFlowLayout and then in interface builder I changed the collection view layout to Custom and selected my flow layout subclass.

Because you're doing it this way you can't specify items sizes, etc... in IB so in MyCollectionViewFlowLayout.m I have this...

- (void)awakeFromNib
{
    self.itemSize = CGSizeMake(75.0, 75.0);
    self.minimumInteritemSpacing = 10.0;
    self.minimumLineSpacing = 10.0;
    self.scrollDirection = UICollectionViewScrollDirectionHorizontal;
    self.sectionInset = UIEdgeInsetsMake(10.0, 10.0, 10.0, 10.0);
}

This sets up all the sizes for me and the scroll direction.

Then ...

- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity
{
    CGFloat offsetAdjustment = MAXFLOAT;
    CGFloat horizontalOffset = proposedContentOffset.x + 5;

    CGRect targetRect = CGRectMake(proposedContentOffset.x, 0, self.collectionView.bounds.size.width, self.collectionView.bounds.size.height);

    NSArray *array = [super layoutAttributesForElementsInRect:targetRect];

    for (UICollectionViewLayoutAttributes *layoutAttributes in array) {
        CGFloat itemOffset = layoutAttributes.frame.origin.x;
        if (ABS(itemOffset - horizontalOffset) < ABS(offsetAdjustment)) {
            offsetAdjustment = itemOffset - horizontalOffset;
        }
    }

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

This ensures that the scrolling ends with a margin of 5.0 on the left hand edge.

That's all I needed to do. I didn't need to set the flow layout in code at all.

Fogmeister
  • 70,181
  • 37
  • 189
  • 274
  • Thanks for your question and answer. Totally helped me out, starting to love UICollectionViews! – nicktones Jan 29 '13 at 16:10
  • 1
    It is really powerful when used properly. Have you watched the Collection View sessions from WWDC 2012? They are really worth watching. Some incredible stuff. – Fogmeister Jan 29 '13 at 16:13
  • 1
    I have yeah great stuff. Your solution works great, except for the first and last cells, which can get 'stick' half off screen. Any ideas on how to solve that? I have a horizontal collection view, much like the app listings in the App Store. – nicktones Jan 29 '13 at 16:19
  • Cheers I'll give it a try. – nicktones Jan 29 '13 at 16:50
  • Got it sorted now. I wasn't getting the same problem as Ajaxharg, so that didn't help. However setting the left and right sectionInset to be the same as the scrolling end margin fixed it. All cells now snap including the first and last. Great QnA Fogmeister! – nicktones Jan 29 '13 at 16:56
  • Thanks for the feedback! Glad you got it sorted :D – Fogmeister Jan 29 '13 at 17:19
  • If you don't want to set the collection view flow layout in Interface Builder to custom, I think an alternative way is to set the class of the Collection View Flow Layout object for your collection view in Interface Builder. I could be wrong, though. – Scott Marchant May 08 '13 at 16:13
  • 2
    `targetContentOffsetForProposedContentOffset:withVelocity` isn't being called for me when I scroll. What's going on? – fatuhoku May 21 '14 at 15:05
  • 1
    when i scroll too fast, it will skip one cell, how can it exactly stop at next / prev cell? – TomSawyer Oct 07 '14 at 18:36
  • 4
    @TomSawyer set the UICollectionView's declaration rate to UIScrollViewDecelerationRateFast. – Clay Ellis Dec 25 '15 at 06:35
  • 4
    @fatuhoku make sure that your collectionView's paginEnabled property is set to false – chrs Apr 14 '16 at 17:32
  • @nicktones can you elaborate on "However setting the left and right sectionInset to be the same as the scrolling end margin fixed it."? – Dylan Bettermann May 23 '16 at 19:52
  • 4
    Holy Moly, I had to scroll down like a million miles to see this answer. :) – AnBisw Jan 26 '18 at 22:28
  • "answer is no, there is no way to do this without subclassing UICollectionViewFlowLayout." well, actually there is... – thibaut noah Oct 09 '19 at 15:06
68

Dan's solution is flawed. It does not handle user flicking well. The cases when user flicks fast and scroll did not move so much, have animation glitches.

My proposed alternative implementation has the same pagination as proposed before, but handles user flicking between pages.

 #pragma mark - Pagination
 - (CGFloat)pageWidth {
     return self.itemSize.width + self.minimumLineSpacing;
 }

 - (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity
 {           
        CGFloat rawPageValue = self.collectionView.contentOffset.x / self.pageWidth;
        CGFloat currentPage = (velocity.x > 0.0) ? floor(rawPageValue) : ceil(rawPageValue);
        CGFloat nextPage = (velocity.x > 0.0) ? ceil(rawPageValue) : floor(rawPageValue);

        BOOL pannedLessThanAPage = fabs(1 + currentPage - rawPageValue) > 0.5;
        BOOL flicked = fabs(velocity.x) > [self flickVelocity];
        if (pannedLessThanAPage && flicked) {
            proposedContentOffset.x = nextPage * self.pageWidth;
        } else {
            proposedContentOffset.x = round(rawPageValue) * self.pageWidth;
        }

        return proposedContentOffset;
 }

 - (CGFloat)flickVelocity {
     return 0.3;
 }
Andy Poes
  • 1,552
  • 14
  • 19
DarthMike
  • 3,246
  • 1
  • 19
  • 18
  • Thank you! This worked like a charm. A little hard to understand but getting there. – Rajiev Timal May 28 '14 at 18:11
  • I'm having this error: Cannot assign to 'x' in 'proposedContentOffset' ? Using swift? how can i assign to x value? – TomSawyer Sep 29 '14 at 11:09
  • @TomSawyer Params are 'let' by default. Try to declare function as this in Swift (using var before param): override func targetContentOffsetForProposedContentOffset(var proposedContentOffset: CGPoint) -> CGPoint – DarthMike Sep 29 '14 at 12:58
  • @DarthMike i already did. But could not set x for proposedContentOffset. And i tried return one specific value return CGPointMake(350,proposedContentOffset.y) but it didn't work – TomSawyer Sep 29 '14 at 17:32
  • 1
    You can't use CGPointMake in swift. I personally used this: "var targetContentOffset: CGPoint if pannedLessThanAPage && flicked { targetContentOffset = CGPoint(x: nextPage * pageWidth(), y: proposedContentOffset.y); } else { targetContentOffset = CGPoint(x: round(rawPageValue) * pageWidth(), y: proposedContentOffset.y); } return proposedContentOffset" – Plot Oct 24 '14 at 09:09
  • I think this sets a wrong value for `nextPage` if/when the velocity is 0.0, for example if the user drags over a little, then just lets go of their finger. I'm seeing it get set to 0 instead of 1. – taber Nov 08 '14 at 10:52
  • Recalculating `nextPage` in the else block seems to fix it: `nextPage = proposedContentOffset.x / self.pageWidth;` – taber Nov 08 '14 at 11:06
  • @taber Interesting. What do you want to fix exactly? If user does not flick, and didn't move more than half page, then nextpage should be the same as original. Can you describe in UI what is wrong in that case? – DarthMike Nov 09 '14 at 11:10
  • Worked for me, but I had trouble at first since I had `pagingEnabled` set to true. – Mark Leonard Jun 21 '16 at 12:09
  • 1
    It should be the selected answer. – khunshan Nov 09 '16 at 16:09
  • Mind blowing man, seriously how did you do it, you are my god :) – Mr. Bean Feb 24 '17 at 07:16
29

Swift version of the accepted answer.

override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
    var offsetAdjustment = CGFloat.greatestFiniteMagnitude
    let horizontalOffset = proposedContentOffset.x
    let targetRect = CGRect(origin: CGPoint(x: proposedContentOffset.x, y: 0), size: self.collectionView!.bounds.size)

    for layoutAttributes in super.layoutAttributesForElements(in: targetRect)! {
        let itemOffset = layoutAttributes.frame.origin.x
        if (abs(itemOffset - horizontalOffset) < abs(offsetAdjustment)) {
            offsetAdjustment = itemOffset - horizontalOffset
        }
    }

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

Valid for Swift 5.

André Abreu
  • 640
  • 6
  • 12
24

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,328
  • 1
  • 17
  • 32
  • 4
    You are the life saviour! Important to note to SET PAGING TO FALSE!!! Lost like 2 hours of my life fixing your function, which already works ... – denis631 Aug 22 '18 at 16:23
  • @denis631 I'm so sorry! I should've added that, I'll edit the post to reflect this! Glad it worked :) – JoniVR Aug 22 '18 at 19:04
  • jesssus, i was wondering why this isn't working until i saw this comment about disabling paging... of course mine was set to true – Kam Wo Jul 26 '19 at 12:52
  • @JoniVR It shows me this error Method does not override any method from its superclass – Muju Mar 28 '20 at 11:30
22

While this answer has been a great help to me, there is a noticeable flicker when you swipe fast on a small distance. It's much easier to reproduce it on the device.

I found that this always happens when collectionView.contentOffset.x - proposedContentOffset.x and velocity.x have different sings.

My solution was to ensure that proposedContentOffset is more than contentOffset.x if velocity is positive, and less if it is negative. It's in C# but should be fairly simple to translate to Objective C:

public override PointF TargetContentOffset (PointF proposedContentOffset, PointF scrollingVelocity)
{
    /* Determine closest edge */

    float offSetAdjustment = float.MaxValue;
    float horizontalCenter = (float) (proposedContentOffset.X + (this.CollectionView.Bounds.Size.Width / 2.0));

    RectangleF targetRect = new RectangleF (proposedContentOffset.X, 0.0f, this.CollectionView.Bounds.Size.Width, this.CollectionView.Bounds.Size.Height);
    var array = base.LayoutAttributesForElementsInRect (targetRect);

    foreach (var layoutAttributes in array) {
        float itemHorizontalCenter = layoutAttributes.Center.X;
        if (Math.Abs (itemHorizontalCenter - horizontalCenter) < Math.Abs (offSetAdjustment)) {
            offSetAdjustment = itemHorizontalCenter - horizontalCenter;
        }
    }

    float nextOffset = proposedContentOffset.X + offSetAdjustment;

    /*
     * ... unless we end up having positive speed
     * while moving left or negative speed while moving right.
     * This will cause flicker so we resort to finding next page
     * in the direction of velocity and use it.
     */

    do {
        proposedContentOffset.X = nextOffset;

        float deltaX = proposedContentOffset.X - CollectionView.ContentOffset.X;
        float velX = scrollingVelocity.X;

        // If their signs are same, or if either is zero, go ahead
        if (Math.Sign (deltaX) * Math.Sign (velX) != -1)
            break;

        // Otherwise, look for the closest page in the right direction
        nextOffset += Math.Sign (scrollingVelocity.X) * SnapStep;
    } while (IsValidOffset (nextOffset));

    return proposedContentOffset;
}

bool IsValidOffset (float offset)
{
    return (offset >= MinContentOffset && offset <= MaxContentOffset);
}

This code is using MinContentOffset, MaxContentOffset and SnapStep which should be trivial for you to define. In my case they turned out to be

float MinContentOffset {
    get { return -CollectionView.ContentInset.Left; }
}

float MaxContentOffset {
    get { return MinContentOffset + CollectionView.ContentSize.Width - ItemSize.Width; }
}

float SnapStep {
    get { return ItemSize.Width + MinimumLineSpacing; }
}
Community
  • 1
  • 1
Dan Abramov
  • 241,321
  • 75
  • 389
  • 492
  • 7
    This works really well. I converted it to Objective-C for those interested: https://gist.github.com/rkeniger/7687301 – Rob Keniger Nov 28 '13 at 04:34
21

After long testing I found solution to snap to center with custom cell width (each cell has diff. width) which fixes the flickering. Feel free to improve the script.

- (CGPoint) targetContentOffsetForProposedContentOffset: (CGPoint) proposedContentOffset withScrollingVelocity: (CGPoint)velocity
{
    CGFloat offSetAdjustment = MAXFLOAT;
    CGFloat horizontalCenter = (CGFloat) (proposedContentOffset.x + (self.collectionView.bounds.size.width / 2.0));

    //setting fastPaging property to NO allows to stop at page on screen (I have pages lees, than self.collectionView.bounds.size.width)
    CGRect targetRect = CGRectMake(self.fastPaging ? proposedContentOffset.x : self.collectionView.contentOffset.x, 
                                   0.0,
                                   self.collectionView.bounds.size.width,
                                   self.collectionView.bounds.size.height);

    NSArray *attributes = [self layoutAttributesForElementsInRect:targetRect];
    NSPredicate *cellAttributesPredicate = [NSPredicate predicateWithBlock: ^BOOL(UICollectionViewLayoutAttributes * _Nonnull evaluatedObject,
                                                                             NSDictionary<NSString *,id> * _Nullable bindings) 
    {
        return (evaluatedObject.representedElementCategory == UICollectionElementCategoryCell); 
    }];        

    NSArray *cellAttributes = [attributes filteredArrayUsingPredicate: cellAttributesPredicate];

    UICollectionViewLayoutAttributes *currentAttributes;

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

    CGFloat nextOffset          = proposedContentOffset.x + offSetAdjustment;

    proposedContentOffset.x     = nextOffset;
    CGFloat deltaX              = proposedContentOffset.x - self.collectionView.contentOffset.x;
    CGFloat velX                = velocity.x;

    // detection form  gist.github.com/rkeniger/7687301
    // based on http://stackoverflow.com/a/14291208/740949
    if (fabs(deltaX) <= FLT_EPSILON || fabs(velX) <= FLT_EPSILON || (velX > 0.0 && deltaX > 0.0) || (velX < 0.0 && deltaX < 0.0)) 
    {

    } 
    else if (velocity.x > 0.0) 
    {
       // revert the array to get the cells from the right side, fixes not correct center on different size in some usecases
        NSArray *revertedArray = [[array reverseObjectEnumerator] allObjects];

        BOOL found = YES;
        float proposedX = 0.0;

        for (UICollectionViewLayoutAttributes *layoutAttributes in revertedArray)
        {
            if(layoutAttributes.representedElementCategory == UICollectionElementCategoryCell)
            {
                CGFloat itemHorizontalCenter = layoutAttributes.center.x;
                if (itemHorizontalCenter > proposedContentOffset.x) {
                     found = YES;
                     proposedX = nextOffset + (currentAttributes.frame.size.width / 2) + (layoutAttributes.frame.size.width / 2);
                } else {
                     break;
                }
            }
        }

       // dont set on unfound element
        if (found) {
            proposedContentOffset.x = proposedX;
        }
    } 
    else if (velocity.x < 0.0) 
    {
        for (UICollectionViewLayoutAttributes *layoutAttributes in cellAttributes)
        {
            CGFloat itemHorizontalCenter = layoutAttributes.center.x;
            if (itemHorizontalCenter > proposedContentOffset.x) 
            {
                proposedContentOffset.x = nextOffset - ((currentAttributes.frame.size.width / 2) + (layoutAttributes.frame.size.width / 2));
                break;
            }
        }
    }

    proposedContentOffset.y = 0.0;

    return proposedContentOffset;
}
Pion
  • 718
  • 9
  • 20
  • 10
    Best solution of them all, thanks! Also to any future readers, you must turn off paging in order for this to work. – sridvijay Jul 28 '14 at 19:35
  • 1
    If one were to want to align it from the left, instead of the cell aligned right in the center, how would we go about changing it? – CyberMew Sep 04 '14 at 06:27
  • No sure if I understand correctly, but if you want to start the items in center, and align it to center, you need to change the contentInset. I use this this: https://gist.github.com/pionl/432fc8059dee3b540e38 – Pion Sep 04 '14 at 09:42
  • To align in the X position of the cell to middle of the view, just remove + (layoutAttributes.frame.size.width / 2) in velocity section. – Pion Sep 04 '14 at 09:45
  • Cannot assign to 'x' in 'proposedContentOffset' in swift – TomSawyer Oct 06 '14 at 21:57
  • @TomSawyer i think in Swift you need to use X and Y in caps. – Pion Oct 08 '14 at 17:10
  • no, it's not. proposedContentOffset.x only can get, not set. so couldnot set value for it. – TomSawyer Oct 09 '14 at 07:57
  • @TomSawyer Then save the new X offset in new variable and in return create new CGPoint from the variables. – Pion Oct 09 '14 at 08:50
  • @Pion great work here - only problem I'm having is that it does not pick up swiping movements, such that a swipe will make the collectionview act like there is no paging at all. Any ideas how to resolve this? – trdavidson Jun 02 '15 at 07:18
  • @trdavidson I'm not experiencing this issue (by some testing I dont see it). I use custom cell size for several cells, that could be why. I have special code to fix the offset when rotating, probably you can use scrollview delegate wait for finish and call thy fix offset. You can get value from targetContentOffsetForProposedContentOffset by passing current contentOffset. My working example is found in Glogster app (on AppleStore). – Pion Jun 02 '15 at 18:38
  • @Pion where do i place this function? – Just a coder Apr 23 '16 at 22:10
  • 1
    @Jay Hi, just create a custom Flow delegate and add this code to it. Don't forget to set the custom layout in nib or code. – Pion Apr 25 '16 at 16:13
20

For anyone looking for a solution that...

  • DOES NOT GLITCH when the user performs a short fast scroll (i.e. it considers positive and negative scroll velocities)
  • takes the collectionView.contentInset (and safeArea on iPhone X) into consideration
  • only considers thoes cells visible at the point of scrolling (for peformance)
  • uses well named variables and comments
  • is Swift 4

then please see below...

public class CarouselCollectionViewLayout: UICollectionViewFlowLayout {

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

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

        // Identify the layoutAttributes of cells in the vicinity of where the scroll view will come to rest
        let targetRect = CGRect(origin: proposedContentOffset, size: collectionView.bounds.size)
        let visibleCellsLayoutAttributes = layoutAttributesForElements(in: targetRect)

        // Translate those cell layoutAttributes into potential (candidate) scrollView offsets
        let candidateOffsets: [CGFloat]? = visibleCellsLayoutAttributes?.map({ cellLayoutAttributes in
            if #available(iOS 11.0, *) {
                return cellLayoutAttributes.frame.origin.x - collectionView.contentInset.left - collectionView.safeAreaInsets.left - sectionInset.left
            } else {
                return cellLayoutAttributes.frame.origin.x - collectionView.contentInset.left - sectionInset.left
            }
        })

        // Now we need to work out which one of the candidate offsets is the best one
        let bestCandidateOffset: CGFloat

        if velocity.x > 0 {
            // If the scroll velocity was POSITIVE, then only consider cells/offsets to the RIGHT of the proposedContentOffset.x
            // Of the cells/offsets to the right, the NEAREST is the `bestCandidate`
            // If there is no nearestCandidateOffsetToLeft then we default to the RIGHT-MOST (last) of ALL the candidate cells/offsets
            //      (this handles the scenario where the user has scrolled beyond the last cell)
            let candidateOffsetsToRight = candidateOffsets?.toRight(ofProposedOffset: proposedContentOffset.x)
            let nearestCandidateOffsetToRight = candidateOffsetsToRight?.nearest(toProposedOffset: proposedContentOffset.x)
            bestCandidateOffset = nearestCandidateOffsetToRight ?? candidateOffsets?.last ?? proposedContentOffset.x
        }
        else if velocity.x < 0 {
            // If the scroll velocity was NEGATIVE, then only consider cells/offsets to the LEFT of the proposedContentOffset.x
            // Of the cells/offsets to the left, the NEAREST is the `bestCandidate`
            // If there is no nearestCandidateOffsetToLeft then we default to the LEFT-MOST (first) of ALL the candidate cells/offsets
            //      (this handles the scenario where the user has scrolled beyond the first cell)
            let candidateOffsetsToLeft = candidateOffsets?.toLeft(ofProposedOffset: proposedContentOffset.x)
            let nearestCandidateOffsetToLeft = candidateOffsetsToLeft?.nearest(toProposedOffset: proposedContentOffset.x)
            bestCandidateOffset = nearestCandidateOffsetToLeft ?? candidateOffsets?.first ?? proposedContentOffset.x
        }
        else {
            // If the scroll velocity was ZERO we consider all `candidate` cells (regarless of whether they are to the left OR right of the proposedContentOffset.x)
            // The cell/offset that is the NEAREST is the `bestCandidate`
            let nearestCandidateOffset = candidateOffsets?.nearest(toProposedOffset: proposedContentOffset.x)
            bestCandidateOffset = nearestCandidateOffset ??  proposedContentOffset.x
        }

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

}

fileprivate extension Sequence where Iterator.Element == CGFloat {

    func toLeft(ofProposedOffset proposedOffset: CGFloat) -> [CGFloat] {

        return filter() { candidateOffset in
            return candidateOffset < proposedOffset
        }
    }

    func toRight(ofProposedOffset proposedOffset: CGFloat) -> [CGFloat] {

        return filter() { candidateOffset in
            return candidateOffset > proposedOffset
        }
    }

    func nearest(toProposedOffset proposedOffset: CGFloat) -> CGFloat? {

        guard let firstCandidateOffset = first(where: { _ in true }) else {
            // If there are no elements in the Sequence, return nil
            return nil
        }

        return reduce(firstCandidateOffset) { (bestCandidateOffset: CGFloat, candidateOffset: CGFloat) -> CGFloat in

            let candidateOffsetDistanceFromProposed = fabs(candidateOffset - proposedOffset)
            let bestCandidateOffsetDistancFromProposed = fabs(bestCandidateOffset - proposedOffset)

            if candidateOffsetDistanceFromProposed < bestCandidateOffsetDistancFromProposed {
                return candidateOffset
            }

            return bestCandidateOffset
        }
    }
}
Oliver Pearmain
  • 17,376
  • 12
  • 77
  • 83
18

refer to this answer by Dan Abramov here's Swift version

    override func targetContentOffsetForProposedContentOffset(proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
    var _proposedContentOffset = CGPoint(x: proposedContentOffset.x, y: proposedContentOffset.y)
    var offSetAdjustment: CGFloat = CGFloat.max
    let horizontalCenter: CGFloat = CGFloat(proposedContentOffset.x + (self.collectionView!.bounds.size.width / 2.0))

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

    let array: [UICollectionViewLayoutAttributes] = self.layoutAttributesForElementsInRect(targetRect)! as [UICollectionViewLayoutAttributes]
    for layoutAttributes: UICollectionViewLayoutAttributes in array {
        if (layoutAttributes.representedElementCategory == UICollectionElementCategory.Cell) {
            let itemHorizontalCenter: CGFloat = layoutAttributes.center.x
            if (abs(itemHorizontalCenter - horizontalCenter) < abs(offSetAdjustment)) {
                offSetAdjustment = itemHorizontalCenter - horizontalCenter
            }
        }
    }

    var nextOffset: CGFloat = proposedContentOffset.x + offSetAdjustment

    repeat {
        _proposedContentOffset.x = nextOffset
        let deltaX = proposedContentOffset.x - self.collectionView!.contentOffset.x
        let velX = velocity.x

        if (deltaX == 0.0 || velX == 0 || (velX > 0.0 && deltaX > 0.0) || (velX < 0.0 && deltaX < 0.0)) {
            break
        }

        if (velocity.x > 0.0) {
            nextOffset = nextOffset + self.snapStep()
        } else if (velocity.x < 0.0) {
            nextOffset = nextOffset - self.snapStep()
        }
    } while self.isValidOffset(nextOffset)

    _proposedContentOffset.y = 0.0

    return _proposedContentOffset
}

func isValidOffset(offset: CGFloat) -> Bool {
    return (offset >= CGFloat(self.minContentOffset()) && offset <= CGFloat(self.maxContentOffset()))
}

func minContentOffset() -> CGFloat {
    return -CGFloat(self.collectionView!.contentInset.left)
}

func maxContentOffset() -> CGFloat {
    return CGFloat(self.minContentOffset() + self.collectionView!.contentSize.width - self.itemSize.width)
}

func snapStep() -> CGFloat {
    return self.itemSize.width + self.minimumLineSpacing;
}

or gist here https://gist.github.com/katopz/8b04c783387f0c345cd9

Community
  • 1
  • 1
katopz
  • 545
  • 5
  • 12
  • 4
    Updated version of this for Swift 3: https://gist.github.com/mstubna/beed10327e00310d05f12bf4747266a4 – mstubna Sep 26 '16 at 11:17
  • 1
    Dang it @mstubna, I went ahead and copied the above, updated it to swift 3, started making an updated gist, and came back here to collect notes/title at which point i noticed that you had already made a swift 3 gist. Thanks! Too bad I missed it. – VaporwareWolf Dec 07 '16 at 23:29
7

Here is my Swift solution on a horizontally scrolling collection view. It's simple, sweet and avoids any flickering.

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

    let currentXOffset = collectionView.contentOffset.x
    let nextXOffset = proposedContentOffset.x
    let maxIndex = ceil(currentXOffset / pageWidth())
    let minIndex = floor(currentXOffset / pageWidth())

    var index: CGFloat = 0

    if nextXOffset > currentXOffset {
      index = maxIndex
    } else {
      index = minIndex
    }

    let xOffset = pageWidth() * index
    let point = CGPointMake(xOffset, 0)

    return point
  }

  func pageWidth() -> CGFloat {
    return itemSize.width + minimumInteritemSpacing
  }
Scott Kaiser
  • 2,953
  • 2
  • 24
  • 41
  • what is `itemSize` ?? – Konstantinos Natsios Aug 10 '16 at 17:39
  • Its the size of the collection cells. These function are used when subclassing UICollectionViewFlowLayout. – Scott Kaiser Aug 10 '16 at 19:50
  • https://developer.apple.com/library/ios/documentation/UIKit/Reference/UICollectionViewFlowLayout_class/index.html#//apple_ref/occ/cl/UICollectionViewFlowLayout – Scott Kaiser Aug 10 '16 at 19:51
  • 1
    I like this solution, but I have a couple comments. `pageWidth()` should use `minimumLineSpacing` since it scrolls horizontally. And in my case, I have a `contentInset` for the collection view so that the first and last cell can be centered, so I use `let xOffset = pageWidth() * index - collectionView.contentInset.left`. – blwinters Apr 17 '18 at 20:59
6

a small issue I encountered while using targetContentOffsetForProposedContentOffset is a problem with the last cell not adjusting according to the new point I returned.
I found out that the CGPoint I returned had a Y value bigger then allowed so i used the following code at the end of my targetContentOffsetForProposedContentOffset implementation:

// if the calculated y is bigger then the maximum possible y we adjust accordingly
CGFloat contentHeight = self.collectionViewContentSize.height;
CGFloat collectionViewHeight = self.collectionView.bounds.size.height;
CGFloat maxY = contentHeight - collectionViewHeight;
if (newY > maxY)
{
    newY = maxY;
}

return CGPointMake(0, newY);

just to make it clearer this is my full layout implementation which just imitates vertical paging behavior:

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

- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset
{
    CGFloat heightOfPage = self.itemSize.height;
    CGFloat heightOfSpacing = self.minimumLineSpacing;

    CGFloat numOfPage = lround(proposedContentOffset.y / (heightOfPage + heightOfSpacing));
    CGFloat newY = numOfPage * (heightOfPage + heightOfSpacing);

    // if the calculated y is bigger then the maximum possible y we adjust accordingly
    CGFloat contentHeight = self.collectionViewContentSize.height;
    CGFloat collectionViewHeight = self.collectionView.bounds.size.height;
    CGFloat maxY = contentHeight - collectionViewHeight;
    if (newY > maxY)
    {
        newY = maxY;
    }

    return CGPointMake(0, newY);
}

hopefully this will save someone some time and a headache

keisar
  • 5,026
  • 5
  • 24
  • 26
  • 1
    Same problem, seems like the collection view will ignore invalid values instead of rounding them off to its bounds. – Mike M Mar 17 '14 at 11:17
6

I prefer to allow user flicking through several pages. So here is my version of targetContentOffsetForProposedContentOffset (which based on DarthMike answer) for vertical layout.

- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity {
    CGFloat approximatePage = self.collectionView.contentOffset.y / self.pageHeight;
    CGFloat currentPage = (velocity.y < 0.0) ? floor(approximatePage) : ceil(approximatePage);

    NSInteger flickedPages = ceil(velocity.y / self.flickVelocity);

    if (flickedPages) {
        proposedContentOffset.y = (currentPage + flickedPages) * self.pageHeight;
    } else {
        proposedContentOffset.y = currentPage * self.pageHeight;
    }

    return proposedContentOffset;
}

- (CGFloat)pageHeight {
    return self.itemSize.height + self.minimumLineSpacing;
}

- (CGFloat)flickVelocity {
    return 1.2;
}
Anton Gaenko
  • 8,664
  • 6
  • 41
  • 38
4

Fogmeisters answer worked for me unless I scrolled to the end of the row. My cells don't fit neatly on the screen so it would scroll to the end and jump back with a jerk so that the last cell always overlapped the right edge of the screen.

To prevent this add the following line of code at the start of the targetcontentoffset method

if(proposedContentOffset.x>self.collectionViewContentSize.width-320-self.sectionInset.right)
    return proposedContentOffset;
Fogmeister
  • 70,181
  • 37
  • 189
  • 274
Ajaxharg
  • 2,942
  • 1
  • 16
  • 19
2

@André Abreu's Code

Swift3 version

class CustomCollectionViewFlowLayout: UICollectionViewFlowLayout {
    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
        var offsetAdjustment = CGFloat.greatestFiniteMagnitude
        let horizontalOffset = proposedContentOffset.x
        let targetRect = CGRect(x: proposedContentOffset.x, y: 0, width: self.collectionView!.bounds.size.width, height: self.collectionView!.bounds.size.height)
        for layoutAttributes in super.layoutAttributesForElements(in: targetRect)! {
            let itemOffset = layoutAttributes.frame.origin.x
            if abs(itemOffset - horizontalOffset) < abs(offsetAdjustment){
                offsetAdjustment = itemOffset - horizontalOffset
            }
        }
        return CGPoint(x: proposedContentOffset.x + offsetAdjustment, y: proposedContentOffset.y)
    }
}
Cruz
  • 2,436
  • 16
  • 26
2

Swift 4

The easiest solution for collection view with cells of one size (horizontal scroll):

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

    // Calculate width of your page
    let pageWidth = calculatedPageWidth()

    // Calculate proposed page
    let proposedPage = round(proposedContentOffset.x / pageWidth)

    // Adjust necessary offset
    let xOffset = pageWidth * proposedPage - collectionView.contentInset.left

    return CGPoint(x: xOffset, y: 0)
}

func calculatedPageWidth() -> CGFloat {
    return itemSize.width + minimumInteritemSpacing
}
lobstah
  • 786
  • 5
  • 12
2

A shorter solution (assuming you're caching your layout attributes):

override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
    let proposedEndFrame = CGRect(x: proposedContentOffset.x, y: 0, width: collectionView!.bounds.width, height: collectionView!.bounds.height)
    let targetLayoutAttributes = cache.max { $0.frame.intersection(proposedEndFrame).width < $1.frame.intersection(proposedEndFrame).width }!
    return CGPoint(x: targetLayoutAttributes.frame.minX - horizontalPadding, y: 0)
}

To put this in context:

class Layout : UICollectionViewLayout {
    private var cache: [UICollectionViewLayoutAttributes] = []
    private static let horizontalPadding: CGFloat = 16
    private static let interItemSpacing: CGFloat = 8

    override func prepare() {
        let (itemWidth, itemHeight) = (collectionView!.bounds.width - 2 * Layout.horizontalPadding, collectionView!.bounds.height)
        cache.removeAll()
        let count = collectionView!.numberOfItems(inSection: 0)
        var x: CGFloat = Layout.horizontalPadding
        for item in (0..<count) {
            let indexPath = IndexPath(item: item, section: 0)
            let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
            attributes.frame = CGRect(x: x, y: 0, width: itemWidth, height: itemHeight)
            cache.append(attributes)
            x += itemWidth + Layout.interItemSpacing
        }
    }

    override var collectionViewContentSize: CGSize {
        let width: CGFloat
        if let maxX = cache.last?.frame.maxX {
            width = maxX + Layout.horizontalPadding
        } else {
            width = collectionView!.width
        }
        return CGSize(width: width, height: collectionView!.height)
    }

    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return cache.first { $0.indexPath == indexPath }
    }

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        return cache.filter { $0.frame.intersects(rect) }
    }

    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
        let proposedEndFrame = CGRect(x: proposedContentOffset.x, y: 0, width: collectionView!.bounds.width, height: collectionView!.bounds.height)
        let targetLayoutAttributes = cache.max { $0.frame.intersection(proposedEndFrame).width < $1.frame.intersection(proposedEndFrame).width }!
        return CGPoint(x: targetLayoutAttributes.frame.minX - Layout.horizontalPadding, y: 0)
    }
}
Niels
  • 291
  • 2
  • 6
1

To make sure it works in Swift version (swift 5 now), I used the answer from @André Abreu, I add some more informations:

When subclassing UICollectionViewFlowLayout, the "override func awakeFromNib(){}" doesn't works (don't know why). Instead, I used "override init(){super.init()}"

This is my code put in class SubclassFlowLayout: UICollectionViewFlowLayout {} :

let padding: CGFloat = 16
override init() {
    super.init()
    self.minimumLineSpacing = padding
    self.minimumInteritemSpacing = 2
    self.scrollDirection = .horizontal
    self.sectionInset = UIEdgeInsets(top: 0, left: padding, bottom: 0, right: 100) //right = "should set for footer" (Horizental)

}

required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
}

override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
    var offsetAdjustment = CGFloat.greatestFiniteMagnitude
    let leftInset = padding
    let horizontalOffset = proposedContentOffset.x + leftInset // leftInset is for "where you want the item stop on the left"
    let targetRect = CGRect(origin: CGPoint(x: proposedContentOffset.x, y: 0), size: self.collectionView!.bounds.size)

    for layoutAttributes in super.layoutAttributesForElements(in: targetRect)! {
        let itemOffset = layoutAttributes.frame.origin.x
        if (abs(itemOffset - horizontalOffset) < abs(offsetAdjustment)) {
            offsetAdjustment = itemOffset - horizontalOffset
        }
    }

    let targetPoint = CGPoint(x: proposedContentOffset.x + offsetAdjustment, y: proposedContentOffset.y)
    return targetPoint

}

After subclassing, make sure to put this in ViewDidLoad():

customCollectionView.collectionViewLayout = SubclassFlowLayout()
customCollectionView.isPagingEnabled = false
customCollectionView.decelerationRate = .fast //-> this for scrollView speed
Tung Dang
  • 56
  • 5
0

For those looking for a solution in Swift:

class CustomCollectionViewFlowLayout: UICollectionViewFlowLayout {
    private let collectionViewHeight: CGFloat = 200.0
    private let screenWidth: CGFloat = UIScreen.mainScreen().bounds.width

    override func awakeFromNib() {
        super.awakeFromNib()

        self.itemSize = CGSize(width: [InsertItemWidthHere], height: [InsertItemHeightHere])
        self.minimumInteritemSpacing = [InsertItemSpacingHere]
        self.scrollDirection = .Horizontal
        let inset = (self.screenWidth - CGFloat(self.itemSize.width)) / 2
        self.collectionView?.contentInset = UIEdgeInsets(top: 0,
                                                         left: inset,
                                                         bottom: 0,
                                                         right: inset)
    }

    override func targetContentOffsetForProposedContentOffset(proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
        var offsetAdjustment = CGFloat.max
        let horizontalOffset = proposedContentOffset.x + ((self.screenWidth - self.itemSize.width) / 2)

        let targetRect = CGRect(x: proposedContentOffset.x, y: 0, width: self.screenWidth, height: self.collectionViewHeight)
        var array = super.layoutAttributesForElementsInRect(targetRect)

        for layoutAttributes in array! {
            let itemOffset = layoutAttributes.frame.origin.x
            if (abs(itemOffset - horizontalOffset) < abs(offsetAdjustment)) {
                offsetAdjustment = itemOffset - horizontalOffset
            }
        }

        return CGPoint(x: proposedContentOffset.x + offsetAdjustment, y: proposedContentOffset.y)
    }
}
Husein Kareem
  • 496
  • 1
  • 4
  • 12
-1

Here is a demo for paging by cell (when scroll fast, not skip one or more cell): https://github.com/ApesTalk/ATPagingByCell