UICollectionView snap scrolling and pagination
The following snapping logic is for a collection with cells of the same size and one section, but the logic for more sections shouldn't be much different, or much more complex.
scrollViewWillEndDragging
has an inout targetContentOffset
parameter, meaning we can read and modify the end position of the scroll. Luckily, we don't need to take into consideration insets, line or item spacing (I've lost a lot of time by including them, then not being able to understand why the correct math produces wrong results):
let cellWidth = collectionView( // 1
collectionView,
layout: collectionView.collectionViewLayout,
sizeForItemAt: IndexPath(item: 0, section: 0)
).width
let page: CGFloat
let snapPoint: CGFloat = 0.3
let snapDelta: CGFloat = 1 - snapPoint
let proposedPage = targetContentOffset.pointee.x / max(1, cellWidth) // 2
if floor(proposedPage + snapDelta) == floor(proposedPage)
&& scrollView.contentOffset.x <= targetContentOffset.pointee.x { // 3
page = floor(proposedPage) // 4
}
else {
page = floor(proposedPage + 1) // 5
}
targetContentOffset.pointee = CGPoint( // 6
x: cellWidth * page,
y: targetContentOffset.pointee.y
)
First, we'll need our cell width (1) so we can calculate the "proposed page" (2): this is the "page" we're at during scrolling (for example 3.25
would mean page 3
and a quarter of the fourth). If our desired snapPoint
is 30%
, then our snapDelta
would be 1 - 0.3 = 0.7
. Think of it like this:
- if we have reached/passed
30%
of a page, then we virtually reached its end and we need to scroll to the beginning of the next one:3.3 + 0.7 = 4.0
andfloor(4.0) > floor(3.0)
(5) - if we haven't reached
30%
of a page, then we need to stay on it by scrolling to its beginning:3.25 + 0.7 = 3.95
andfloor(3.95) == floor(3.0)
(4)
We also need to consider the case where the user scrolls past the last page (3) - the targetContentOffset
will be within bounds, but the current contentOffset
won't be, so we need to check for that as well (5).
Finally, we replace the targetContentOffset
with our computed value (6).
Update, Feb 13, 2017: Based on the previous point of "insets don't have to be taken into account", I've lost a lot of time in a recent project, when I actually had to take them into consideration (for "true" pagination, at least), so I'm a bit lost about this.
If "true" pagination is desired, as in scroll one page at a time, we need to change things a little bit:
private var startingScrollingOffset = CGPoint.zero
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
startingScrollingOffset = scrollView.contentOffset // 1
}
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
// [...]
let offset = scrollView.contentOffset.x + scrollView.contentInset.left // 2
let proposedPage = offset / max(1, cellWidth)
let snapPoint: CGFloat = 0.1
let snapDelta: CGFloat = offset > startingScrollingOffset.x ? (1 - snapPoint) : snapPoint
if floor(proposedPage + snapDelta) == floor(proposedPage) { // 3
page = floor(proposedPage) // 4
}
else {
page = floor(proposedPage + 1) // 5
}
targetContentOffset.pointee = CGPoint(
x: cellWidth * page,
y: targetContentOffset.pointee.y
)
}
We'll now need to save the position when scrolling started (1). Then, we'll use the current contentOffset
and left inset instead of the targetContentOffset
(2). The snapDelta
logic changes too, as follows, based on direction:
-
swiping left:
- if we have reached/passed
10%
of the current page, then we virtually reached its end and we need to scroll to the next one:3.1 + 0.9 = 4.0
andfloor(4.0) > floor(3.0)
(5) - if we haven't reached
10%
of the current page, we need to stay on it, by scrolling back to our starting point:3.05 + 0.9 = 3.95
andfloor(3.95) == floor(3.0)
(4)
- if we have reached/passed
-
swiping right:
- if we have passed
90%
of the previous page, then we virtually passed its end and we need to scroll to its beginning:2.89 + 0.1 = 2.99
andfloor(2.99) < floor(3.0)
(5) - if we haven't reached
90%
of the previous page, we need to stay on the current page, by scrolling back to our starting point:2.91 + 0.1 = 3.01
andfloor(3.01) == floor(3.0)
(4)
- if we have passed
As for calculating our page: we'll remove the targetContentOffset
logic from the condition and use the snapDelta
we just computed (3) instead of a flat value of 0.7
.
While the percentages were randomly picked, 0.1
feels a bit better for true pagination, while 0.3
feels better for snapped scrolling.
You can find more articles like this on my blog, or you can subscribe to my monthly newsletter. Originally published at https://rolandleth.com.
“Luckily, we don’t need to take into consideration insets, line or item spacing”
That’s actually not true if you need to center your cells with spacings on both sides :)
Great post though