May 20, 2016:  ~ 8 min read

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 and floor(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 and floor(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 and floor(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 and floor(3.95) == floor(3.0) (4)
  • 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 and floor(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 and floor(3.01) == floor(3.0) (4)

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. If you have any feedback, drop by to chat @rolandleth.

Subscribe to my monthly newsletter.
No spam, unsubscribe at any time.