Skip to content

Instantly share code, notes, and snippets.

@mmick66
Last active August 2, 2022 10:06
Show Gist options
  • Star 88 You must be signed in to star a gist
  • Fork 5 You must be signed in to fork a gist
  • Save mmick66/9812223 to your computer and use it in GitHub Desktop.
Save mmick66/9812223 to your computer and use it in GitHub Desktop.
UICollectionViewFlowLayout with arbitrary sized Paging
#import "UICollectionViewFlowLayoutCenterItem.h"
@implementation UICollectionViewFlowLayoutCenterItem
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity
{
CGSize collectionViewSize = self.collectionView.bounds.size;
CGFloat proposedContentOffsetCenterX = proposedContentOffset.x + self.collectionView.bounds.size.width * 0.5f;
CGRect proposedRect = self.collectionView.bounds;
// Comment out if you want the collectionview simply stop at the center of an item while scrolling freely
// proposedRect = CGRectMake(proposedContentOffset.x, 0.0, collectionViewSize.width, collectionViewSize.height);
UICollectionViewLayoutAttributes* candidateAttributes;
for (UICollectionViewLayoutAttributes* attributes in [self layoutAttributesForElementsInRect:proposedRect])
{
// == Skip comparison with non-cell items (headers and footers) == //
if (attributes.representedElementCategory != UICollectionElementCategoryCell)
{
continue;
}
// == First time in the loop == //
if(!candidateAttributes)
{
candidateAttributes = attributes;
continue;
}
if (fabsf(attributes.center.x - proposedContentOffsetCenterX) < fabsf(candidateAttributes.center.x - proposedContentOffsetCenterX))
{
candidateAttributes = attributes;
}
}
return CGPointMake(candidateAttributes.center.x - self.collectionView.bounds.size.width * 0.5f, proposedContentOffset.y);
}
@end
@mmick66
Copy link
Author

mmick66 commented Mar 27, 2014

This will snap a UICollectionView with no paging to the center of an Item depending on the direction and speed with which the user scrolls

@barfoon
Copy link

barfoon commented Aug 17, 2014

CGSize collectionViewSize = self.collectionView.bounds.size; is unused here

@ZachOrr
Copy link

ZachOrr commented Feb 2, 2015

collectionViewSize is used, if you uncomment changing proposedRect

@alexszilagyi
Copy link

Is there something that I'm missing here? Disabling the "Paged enabled" and still doesn't quite works. Any ideea?

Updated snippets code for both version which seems to work fine for me

Vertical centering

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

        let rectBounds:CGRect = self.collectionView!.bounds
        let halfHeight:CGFloat = rectBounds.size.height * CGFloat(0.45)
        let proposedContentOffsetCenterY:CGFloat = proposedContentOffset.y + halfHeight

        let attributesArray:NSArray = self.layoutAttributesForElementsInRect(rectBounds)!

        var candidateAttributes:UICollectionViewLayoutAttributes?

        for layoutAttributes : AnyObject in attributesArray {

            if let _layoutAttributes = layoutAttributes as? UICollectionViewLayoutAttributes {

                if _layoutAttributes.representedElementCategory != UICollectionElementCategory.Cell {
                    continue
                }

                if candidateAttributes == nil {
                    candidateAttributes = _layoutAttributes
                    continue
                }

                if fabsf(Float(_layoutAttributes.center.y) - Float(proposedContentOffsetCenterY)) < fabsf(Float(candidateAttributes!.center.y) - Float(proposedContentOffsetCenterY)) {
                    candidateAttributes = _layoutAttributes
                }
            }
        }

        if attributesArray.count == 0 {
            return CGPointMake(proposedContentOffset.x,proposedContentOffset.y - halfHeight * 2)
        }

        return CGPointMake(proposedContentOffset.x,candidateAttributes!.center.y - halfHeight)
    }

Horizontal centering

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

    let collectionViewSize: CGSize = self.collectionView!.bounds.size
    let rectBounds: CGRect = self.collectionView!.bounds
    let halfWidth: CGFloat = rectBounds.size.width * CGFloat(0.50)
    let proposedContentOffsetCenterX: CGFloat = proposedContentOffset.x + halfWidth

    let proposedRect: CGRect = self.collectionView!.bounds

    let attributesArray:NSArray = self.layoutAttributesForElementsInRect(proposedRect)!

    var candidateAttributes:UICollectionViewLayoutAttributes?


    for layoutAttributes : AnyObject in attributesArray {

      if let _layoutAttributes = layoutAttributes as? UICollectionViewLayoutAttributes {

        if _layoutAttributes.representedElementCategory != UICollectionElementCategory.Cell {
          continue
        }

        if candidateAttributes == nil {
          candidateAttributes = _layoutAttributes
          continue
        }

        if fabsf(Float(_layoutAttributes.center.x) - Float(proposedContentOffsetCenterX)) < fabsf(Float(candidateAttributes!.center.x) - Float(proposedContentOffsetCenterX)) {
          candidateAttributes = _layoutAttributes
        }

      }
    }

    if attributesArray.count == 0 {
      return CGPoint(x: proposedContentOffset.x - halfWidth * 2,y: proposedContentOffset.y)
    }

    return CGPoint(x: candidateAttributes!.center.x - halfWidth, y: proposedContentOffset.y)
  }

I'd like to center vertically, not horizontally (based on @mmick66's example)

@banaslee
Copy link

banaslee commented Jun 5, 2015

It worked awesomely. I just added the following right at the top of the function. This makes the usage more clear.

//For this to work paging must be disabled if not the return value will be overriden down the line by the scroll view
    if (self.collectionView.pagingEnabled) {
        return proposedContentOffset;
    }

Copy link

ghost commented Oct 26, 2015

It wont work for the last cell. Always stays in the middle if moved couple of pixels.
Am I missing something

@vargarobert
Copy link

@mosn
any ideas on how to fix the last cell?

@vargarobert
Copy link

@efremidze
Copy link

Vertical concise version

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

    let halfHeight = collectionView.bounds.height / 2
    let proposedContentOffsetCenterY = proposedContentOffset.y + halfHeight

    let layoutAttributes = layoutAttributesForElementsInRect(collectionView.bounds)
    let closest = layoutAttributes?.sort { abs($0.center.y - proposedContentOffsetCenterY) < abs($1.center.y - proposedContentOffsetCenterY) }.first ?? UICollectionViewLayoutAttributes()

    return CGPoint(x: proposedContentOffset.x, y: closest.center.y - halfHeight)
}

@krupanshu
Copy link

Any luck with Last cell ? or Any idea how to fix the last cell ?

@efremidze
Copy link

override func collectionViewContentSize() -> CGSize {
    if let
        collectionView = collectionView,
        first = layoutAttributesForItemAtIndexPath(NSIndexPath(forItem: 0, inSection: 0)),
        last = layoutAttributesForItemAtIndexPath(NSIndexPath(forItem: collectionView.numberOfItemsInSection(0) - 1, inSection: 0))
    {
        sectionInset = UIEdgeInsets(top: (collectionView.bounds.height / 2) - (first.bounds.height / 2), left: 0, bottom: (collectionView.bounds.height / 2) - (last.bounds.height / 2), right: 0)
    }
    return super.collectionViewContentSize()
}

You can adjust the sectionInset or collectionView.contentInset.

@hemang-azilen
Copy link

I am aware that there're many questions was already asked for this issue. But most of the questions are outdated or with no answers. The problem with my implementation is not preview, but its pagination speed?

I am able to show previous/next cell in UICollectionView but when I try to scroll it speedily, its scrolling (by skipping) 1 or 2 pages. This happens when I scrolling it at good speed.

For a note, I'm using custom layout.

This is my code:

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

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

    NSArray* attributesArray = [self layoutAttributesForElementsInRect:cvBounds];

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

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

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

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

    return CGPointMake(round(candidateAttributes.center.x - halfWidth), proposedContentOffset.y);
}

Any help would be highly appreciated.

@Galinari
Copy link

Great Solution!

There's one caveat though.
It's considering items in the opposite direction of the scroll. If the user scroll even just a little bit to the right they still don't want the current item. So here's my collaboration. (It's swift, but shall be easy enough to translate for those who need Obj-C)

override func targetContentOffsetForProposedContentOffset(proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
    let collectionViewSize = self.collectionView!.bounds.size
    let proposedContentOffsetCenterX = proposedContentOffset.x + collectionViewSize.width * 0.5

    let proposedRect = self.collectionView!.bounds

    // Comment out if you want the collectionview simply stop at the center of an item while scrolling freely
    // proposedRect = CGRect(x: proposedContentOffset.x, y: 0, width: collectionViewSize.width, height: collectionViewSize.height)

    var candidateAttributes: UICollectionViewLayoutAttributes?
    for attributes in self.layoutAttributesForElementsInRect(proposedRect)! {
        // == Skip comparison with non-cell items (headers and footers) == //
        if attributes.representedElementCategory != .Cell {
            continue
        }


        // Get collectionView current scroll position
        let currentOffset = self.collectionView!.contentOffset

        // Don't even bother with items on opposite direction
        // You'll get at least one, or else the fallback got your back
        if (attributes.center.x < (currentOffset.x + collectionViewSize.width * 0.5) && velocity.x > 0) || (attributes.center.x > (currentOffset.x + collectionViewSize.width * 0.5) && velocity.x < 0) {
            continue
        }


        // First good item in the loop
        if candidateAttributes == nil {
            candidateAttributes = attributes
            continue
        }


        // Save constants to improve readability
        let lastCenterOffset = candidateAttributes!.center.x - proposedContentOffsetCenterX
        let centerOffset = attributes.center.x - proposedContentOffsetCenterX

        if fabsf( Float(centerOffset) ) < fabsf( Float(lastCenterOffset) ) {
            candidateAttributes = attributes
        }
    }

    if candidateAttributes != nil {
        // Great, we have a candidate
        return CGPoint(x: candidateAttributes!.center.x - collectionViewSize.width * 0.5, y: proposedContentOffset.y)
    } else {
        // Fallback
        return super.targetContentOffsetForProposedContentOffset(proposedContentOffset)
    }


}

@maulikpat
Copy link

@mmick66 : I used your given solution for collection view.

But its scrolling didn't stop, it scrolls like there is no paging enabled.

@mcginnik
Copy link

mcginnik commented Jun 8, 2016

Here you go - fixed the last cell issue ( it needed some "<=" & ">=" love) I incorporated what everyone added so this should center the middle element and keep it centered at the ends using insets.



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

        if let collectionView = collectionView,
            first = layoutAttributesForItemAtIndexPath(NSIndexPath(forItem: 0, inSection: 0)),
            last = layoutAttributesForItemAtIndexPath(NSIndexPath(forItem: collectionView.numberOfItemsInSection(0) - 1, inSection: 0))
        {
            sectionInset = UIEdgeInsets(top: 0, left: collectionView.frame.width / 2 - first.bounds.size.width / 2, bottom: 0, right: collectionView.frame.width / 2  - last.bounds.size.width / 2)
        }

        let collectionViewSize = self.collectionView!.bounds.size
        let proposedContentOffsetCenterX = proposedContentOffset.x + collectionViewSize.width * 0.5

        var proposedRect = self.collectionView!.bounds

        // comment this out if you don't want it to scroll so quickly
        proposedRect = CGRect(x: proposedContentOffset.x, y: 0, width: collectionViewSize.width, height: collectionViewSize.height)

        var candidateAttributes: UICollectionViewLayoutAttributes?
        for attributes in self.layoutAttributesForElementsInRect(proposedRect)! {
            // == Skip comparison with non-cell items (headers and footers) == //
            if attributes.representedElementCategory != .Cell {
                continue
            }


            // Get collectionView current scroll position
            let currentOffset = self.collectionView!.contentOffset

            // Don't even bother with items on opposite direction
            // You'll get at least one, or else the fallback got your back
            if (attributes.center.x <= (currentOffset.x + collectionViewSize.width * 0.5) && velocity.x > 0) || (attributes.center.x >= (currentOffset.x + collectionViewSize.width * 0.5) && velocity.x < 0) {
                continue
            }


            // First good item in the loop
            if candidateAttributes == nil {
                candidateAttributes = attributes
                continue
            }

            // Save constants to improve readability
            let lastCenterOffset = candidateAttributes!.center.x - proposedContentOffsetCenterX
            let centerOffset = attributes.center.x - proposedContentOffsetCenterX

            if fabsf( Float(centerOffset) ) < fabsf( Float(lastCenterOffset) ) {
                candidateAttributes = attributes
            }
        }

        if candidateAttributes != nil {
            // Great, we have a candidate
            return CGPoint(x: candidateAttributes!.center.x - collectionViewSize.width * 0.5, y: proposedContentOffset.y)
        } else {
            // Fallback
            return super.targetContentOffsetForProposedContentOffset(proposedContentOffset)
        }
    }

@DavidSchechter
Copy link

Is there a way to have this and only move one page at a time like pagingEnabled?

@deepakdhanaie
Copy link

How could i make it circular or infinite with item at centre?

@shivang2902
Copy link

shivang2902 commented Mar 22, 2017

@DavidSchechter did you find any solution for your concern ????

@Nautiyalsachin
Copy link

@mcginnik I loved your answer, just converted it to Swift 3. Working perfect for me.

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

    if let collectionView = collectionView,
        let first = layoutAttributesForItem(at: IndexPath(row: 0, section: 0)),
        let last = layoutAttributesForItem(at: IndexPath(row: collectionView.numberOfItems(inSection: 0) - 1, section: 0))
    {
        sectionInset = UIEdgeInsets(top: 0, left: collectionView.frame.width / 2 - first.bounds.size.width / 2, bottom: 0, right: collectionView.frame.width / 2  - last.bounds.size.width / 2)
    }
    
    let collectionViewSize = self.collectionView!.bounds.size
    let proposedContentOffsetCenterX = proposedContentOffset.x + collectionViewSize.width * 0.5
    
    var proposedRect = self.collectionView!.bounds
    
    // comment this out if you don't want it to scroll so quickly
    proposedRect = CGRect(x: proposedContentOffset.x, y: 0, width: collectionViewSize.width, height: collectionViewSize.height)
    
    var candidateAttributes: UICollectionViewLayoutAttributes?
    for attributes in self.layoutAttributesForElements(in: proposedRect)! {
        // == Skip comparison with non-cell items (headers and footers) == //
        if attributes.representedElementCategory != .cell {
            continue
        }
        
        
        // Get collectionView current scroll position
        let currentOffset = self.collectionView!.contentOffset
        
        // Don't even bother with items on opposite direction
        // You'll get at least one, or else the fallback got your back
        if (attributes.center.x <= (currentOffset.x + collectionViewSize.width * 0.5) && velocity.x > 0) || (attributes.center.x >= (currentOffset.x + collectionViewSize.width * 0.5) && velocity.x < 0) {
            continue
        }
        
        
        // First good item in the loop
        if candidateAttributes == nil {
            candidateAttributes = attributes
            continue
        }
        
        // Save constants to improve readability
        let lastCenterOffset = candidateAttributes!.center.x - proposedContentOffsetCenterX
        let centerOffset = attributes.center.x - proposedContentOffsetCenterX
        
        if fabsf( Float(centerOffset) ) < fabsf( Float(lastCenterOffset) ) {
            candidateAttributes = attributes
        }
    }
    
    if candidateAttributes != nil {
        // Great, we have a candidate
        return CGPoint(x: candidateAttributes!.center.x - collectionViewSize.width * 0.5, y: proposedContentOffset.y)
    } else {
        // Fallback
        return super.targetContentOffset(forProposedContentOffset: proposedContentOffset)
    }
}

Thanks!

@iniko1983x
Copy link

Hi!
Loved the @efremidze answer.
I simple remove "paging snap", and now collection view will move on centered element due several pages:

`
class CollectionViewFlowLayoutCenterItem: UICollectionViewFlowLayout {

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

	var result = super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)

	guard let collectionView = collectionView else {
		return result
	}

	let halfWidth = 0.5 * collectionView.bounds.size.width
	let proposedContentCenterX = result.x + halfWidth

	let targetRect = CGRect(origin: result, size: collectionView.bounds.size)
	let layoutAttributes = layoutAttributesForElements(in: targetRect)?
		.filter { $0.representedElementCategory == .cell }
		.sorted { abs($0.center.x - proposedContentCenterX) < abs($1.center.x - proposedContentCenterX) }

	guard let closest = layoutAttributes?.first else {
		return result
	}

	result = CGPoint(x: closest.center.x - halfWidth, y: proposedContentOffset.y)
	return result
}

}
`

@J7mbo
Copy link

J7mbo commented Jan 8, 2018

@iniko1983x's solution works very nicely, although both the first element and last elements are centred instead of left and right aligned respectively

@romanfurman6
Copy link

Maybe some one need

  override func layoutSubviews() {
    super.layoutSubviews()
    if
      let first = collectionView.layoutAttributesForItem(at: IndexPath(item: 0, section: 0)),
      let last = collectionView.layoutAttributesForItem(at: IndexPath(item: collectionView.numberOfItems(inSection: 0) - 1, section: 0)) {

      let left = collectionView.frame.width / 2 - first.bounds.size.width / 2
      let right = collectionView.frame.width / 2  - last.bounds.size.width / 2
      collectionView.contentInset = UIEdgeInsets(top: 0.0, left: left, bottom: 0.0, right: right)
    }
    self.collectionView.decelerationRate = UIScrollViewDecelerationRateFast
  }
class CenterCellCollectionViewFlowLayout: UICollectionViewFlowLayout {

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

    let collectionViewSize = self.collectionView!.bounds.size
    let proposedContentOffsetCenterX = proposedContentOffset.x + collectionViewSize.width * 0.5

    var proposedRect = self.collectionView!.bounds

    // comment this out if you don't want it to scroll so quickly
    proposedRect = CGRect(x: proposedContentOffset.x, y: 0, width: collectionViewSize.width, height: collectionViewSize.height)

    var candidateAttributes: UICollectionViewLayoutAttributes?
    for attributes in self.layoutAttributesForElements(in: proposedRect)! {
      // == Skip comparison with non-cell items (headers and footers) == //
      if attributes.representedElementCategory != .cell {
        continue
      }

      // Get collectionView current scroll position
      let currentOffset = self.collectionView!.contentOffset

      // Don't even bother with items on opposite direction
      // You'll get at least one, or else the fallback got your back
      // swiftlint:disable:next line_length
      if (attributes.center.x <= (currentOffset.x + collectionViewSize.width * 0.5) && velocity.x > 0) || (attributes.center.x >= (currentOffset.x + collectionViewSize.width * 0.5) && velocity.x < 0) {

        continue
      }

      // First good item in the loop
      if candidateAttributes == nil {
        candidateAttributes = attributes
        continue
      }

      // Save constants to improve readability
      let lastCenterOffset = candidateAttributes!.center.x - proposedContentOffsetCenterX
      let centerOffset = attributes.center.x - proposedContentOffsetCenterX

      if fabsf( Float(centerOffset) ) < fabsf( Float(lastCenterOffset) ) {
        candidateAttributes = attributes
      }
    }

    if candidateAttributes != nil {
      // Great, we have a candidate
      return CGPoint(x: candidateAttributes!.center.x - collectionViewSize.width * 0.5, y: proposedContentOffset.y)
    } else {
      // Fallback
      return super.targetContentOffset(forProposedContentOffset: proposedContentOffset)
    }
  }

}

@Iraniya
Copy link

Iraniya commented Mar 22, 2018

@DavidSchechter did you found the solution for scrolling one cell at a time using this? or anyone else?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment