Paging UICollectionView by cells, not screen
OK, so I found the solution here: targetContentOffsetForProposedContentOffset:withScrollingVelocity without subclassing UICollectionViewFlowLayout
I should have searched for targetContentOffsetForProposedContentOffset
in the begining.
Horizontal Paging With Custom Page Width (Swift 4 & 5)
Many solutions presented here result in some weird behaviour that doesn't feel like properly implemented paging.
The solution presented in this tutorial, however, doesn't seem to have any issues. It just feels like a perfectly working paging algorithm. You can implement it in 5 simple steps:
- Add the following property to your type:
private var indexOfCellBeforeDragging = 0
- Set the
collectionView
delegate
like this:collectionView.delegate = self
- Add conformance to
UICollectionViewDelegate
via an extension:extension YourType: UICollectionViewDelegate { }
Add the following method to the extension implementing the
UICollectionViewDelegate
conformance and set a value forpageWidth
:func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { let pageWidth = // The width your page should have (plus a possible margin) let proportionalOffset = collectionView.contentOffset.x / pageWidth indexOfCellBeforeDragging = Int(round(proportionalOffset)) }
Add the following method to the extension implementing the
UICollectionViewDelegate
conformance, set the same value forpageWidth
(you may also store this value at a central place) and set a value forcollectionViewItemCount
:func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) { // Stop scrolling targetContentOffset.pointee = scrollView.contentOffset // Calculate conditions let pageWidth = // The width your page should have (plus a possible margin) let collectionViewItemCount = // The number of items in this section let proportionalOffset = collectionView.contentOffset.x / pageWidth let indexOfMajorCell = Int(round(proportionalOffset)) let swipeVelocityThreshold: CGFloat = 0.5 let hasEnoughVelocityToSlideToTheNextCell = indexOfCellBeforeDragging + 1 < collectionViewItemCount && velocity.x > swipeVelocityThreshold let hasEnoughVelocityToSlideToThePreviousCell = indexOfCellBeforeDragging - 1 >= 0 && velocity.x < -swipeVelocityThreshold let majorCellIsTheCellBeforeDragging = indexOfMajorCell == indexOfCellBeforeDragging let didUseSwipeToSkipCell = majorCellIsTheCellBeforeDragging && (hasEnoughVelocityToSlideToTheNextCell || hasEnoughVelocityToSlideToThePreviousCell) if didUseSwipeToSkipCell { // Animate so that swipe is just continued let snapToIndex = indexOfCellBeforeDragging + (hasEnoughVelocityToSlideToTheNextCell ? 1 : -1) let toValue = pageWidth * CGFloat(snapToIndex) UIView.animate( withDuration: 0.3, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: velocity.x, options: .allowUserInteraction, animations: { scrollView.contentOffset = CGPoint(x: toValue, y: 0) scrollView.layoutIfNeeded() }, completion: nil ) } else { // Pop back (against velocity) let indexPath = IndexPath(row: indexOfMajorCell, section: 0) collectionView.scrollToItem(at: indexPath, at: .left, animated: true) } }
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 usingcollectionView(_: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.
just override the method:
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {
*targetContentOffset = scrollView.contentOffset; // set acceleration to 0.0
float pageWidth = (float)self.articlesCollectionView.bounds.size.width;
int minSpace = 10;
int cellToSwipe = (scrollView.contentOffset.x)/(pageWidth + minSpace) + 0.5; // cell width + min spacing for lines
if (cellToSwipe < 0) {
cellToSwipe = 0;
} else if (cellToSwipe >= self.articles.count) {
cellToSwipe = self.articles.count - 1;
}
[self.articlesCollectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForRow:cellToSwipe inSection:0] atScrollPosition:UICollectionViewScrollPositionLeft animated:YES];
}