UICollectionView snap scrolling and pagination

:  ~ 3 min read

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:

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:

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.