Tutorial: Horizontal UICollectionView with paging

In this tutorial we will build a simple app that will help us choose a background color from a set of color palettes.

Shai Balassiano
7 min readAug 3, 2017

UPDATE: I added a simpler version of the finished project (can be found at the bottom of the tutorial) so it will be easier to adopt the concepts shown in this tutorial to your own needs.

UICollectionView inherits from a UIScrollView, so paging should be supported, right?

Well, it depends on what is the size of the “page”: If the page size is the same as the size of the screen then yes, just mark “enable paging” property in the interface builder. But, in my design, each cell (page) is smaller then the size of the collectionView. The cells are centered and the previous cell and the next cell peek from the sides.

The first cell and the last cell are also centered in the collectionView so there has to be a left section inset and right section inset (both equal)

So just marking “enable paging” in the interface builder won’t work — we will have to implement paging ourselves!

For this tutorial I already created a starter app that does all the above. The collection view in the bottom of the screen is missing one thing — paging!

You can download the starter project from Github and you will find a link to the finished project at the bottom of the tutorial.

Open the starter project and go to the file ViewController, there I’m setting the cell size and the insets of the collectionView inside the function configureCollectionViewLayoutItemSize() — the inset can be of any size for paging to work, but I wanted the next cell and previous cell to peek from the sides, so calculation for the inset is made in the function calculateSectionInset(). This function does some magic calculations — don’t worry about it, these calculations are very specific for this example.

Next, the spacing between two cells should be zero. It will make calculation much easier for us. We can create the illusion of spacing between cells by encapsulating the inner elements of the cells (like labels, images or in our case: buttons) in the center of cell (see Main.storyboard).

We will achieve zero spacing between cells by adding the following line to the function viewDidLoad():

collectionViewLayout.minimumLineSpacing = 0

Now all is left is for our viewController to conform to UICollectionViewDelegate and to implement the following function:

func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>)

Remember: collectionView is a UIScrollView and UICollectionViewDelegate inherits from UIScrollViewDelegate.

This function will gives us the velocity of the scrollView in the moment the user lift the finger and stopped the dragging gesture. The velocity may be different then zero if the user lift the finger with a swing movement.

The second parameter of this function is a pointer to the contentOffset the scrollView will have once the sliding will come to an end (parameter targetContentOffset).

When the user finished a drag, we would like to snap to the page that appears the most — “the major cell”:

So first, if the user left us velocity, there will be still sliding going and that will interfere with our snapping plan. And here we Have our first line of code inside the function scrollViewWillEndDragging:

// Stop scrollView sliding:
targetContentOffset.pointee = scrollView.contentOffset

This will just stop any sliding effect after the user lift the finger.

Next, we can calculate the major cell index (the cell that we need to snap to) Add the following function to the viewController:

private func indexOfMajorCell() -> Int {let itemWidth = collectionViewLayout.itemSize.widthlet proportionalOffset = collectionViewLayout.collectionView!.contentOffset.x / itemWidthlet index = Int(round(proportionalOffset))let safeIndex = max(0, min(dataSource.count - 1, index))return safeIndexlet proportionalOffset = collectionViewLayout.collectionView!.contentOffset.x / itemWidthlet index = Int(round(proportionalOffset))let safeIndex = max(0, min(dataSource.count - 1, index))return safeIndex}

and at the end of the function scrollViewWillEndDragging add the following line:

// Calculate where scrollView should snap to:let indexOfMajorCell = self.indexOfMajorCell()

And lastly, in order to snap to the cell we’ll ask the collectionView to scroll to that cell:

let indexPath = IndexPath(row: indexOfMajorCell, section: 0)collectionViewLayout.collectionView!.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)

Great! We got paging! But… what if we use a swipe gesture? A swipe gesture will move the current cell a little and snap right back to it! That’s usually not what we expect from a swipe — it should skip a cell! If a swipe is to the right we should snap to the previous cell and if the swipe is to the left we should snap to the next cell:

I guess we can add a UISwipeGestureRecognizer but then it might conflict with the snapping we just implemented. It will be hard to synchronize between the gesture recognizer call-back and the call-back for end dragging on the scrollView.

But we can also recognize the swipe gesture inside the scrollViewWillEndDragging call-back function— here’s how:

First, let’s change the last lines we added:

let indexPath = IndexPath(row: indexOfMajorCell, section: 0)collectionViewLayout.collectionView!.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)

To:

let didUseSwipeToSkipCell = falseif didUseSwipeToSkipCell {// Here we’ll add the code to snap the next cell// or to the previous cell} else {let indexPath = IndexPath(row: indexOfMajorCell, section: 0)collectionViewLayout.collectionView!.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)}

Now the app should run just as before and we made room for some changes.

How will we recognize a swipe? If the user used a swipe gesture then, on dragging end, the scrollView will have velocity different then zero.

the velocity is a CGFloat — the x value is for horizontal velocity and the y is for vertical velocity. We are interested only in horizontal velocity (we scroll horizontally after all). The value velocity.x can be positive if the direction of movement is to the right and can be negative if the direction of movement is to the left.

After some trial and error, I noticed that when I perform a swipe gesture on the collectionView, the velocity is always greater then 0.5 or smaller then -0.5 (depends on the direction of movement).

So we found a swipe gesture velocity threshold to be 0.5 (in absolute value).

Now, let’s understand how to calculate the value of didUseSwipeToSkipCell:

let didUseSwipeToSkipCell = majorCellIsTheCellBeforeDragging && (hasEnoughVelocityToSlideToTheNextCell || hasEnoughVelocityToSlideToThePreviousCell)

So, the first condition is majorCellIsTheCellBeforeDragging meaning we’re going to snap right back to the same cell that was centered in the collectionView, before the dragging started. The second and the third conditions are very similar to each other — that we have enough velocity to skip cell.

In order to calculate majorCellIsTheCellBeforeDragging, we’ll have to get the index of the cell before dragging.

Add the following code just above the function scrollViewWillEndDragging:

private var indexOfCellBeforeDragging = 0func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {indexOfCellBeforeDragging = indexOfMajorCell()}

Here we again use the function indexOfMajorCell() This time to calculate the index of cell before dragging started.

And now we can define majorCellIsTheCellBeforeDragging to be:

let majorCellIsTheCellBeforeDragging = indexOfMajorCell == indexOfCellBeforeDragging

And the other two values are:

let hasEnoughVelocityToSlideToTheNextCell = indexOfCellBeforeDragging + 1 < dataSource.count && velocity.x > swipeVelocityThresholdlet hasEnoughVelocityToSlideToThePreviousCell = indexOfCellBeforeDragging — 1 >= 0 && velocity.x < -swipeVelocityThreshold

Notice that I make sure there’s a next cell or a previous cell.

Now, just add all the lines we listed above:

// calculate conditions:let swipeVelocityThreshold: CGFloat = 0.5let hasEnoughVelocityToSlideToTheNextCell = indexOfCellBeforeDragging + 1 < dataSource.count && velocity.x > swipeVelocityThresholdlet hasEnoughVelocityToSlideToThePreviousCell = indexOfCellBeforeDragging — 1 >= 0 && velocity.x < -swipeVelocityThresholdlet majorCellIsTheCellBeforeDragging = indexOfMajorCell == indexOfCellBeforeDragginglet didUseSwipeToSkipCell = majorCellIsTheCellBeforeDragging && (hasEnoughVelocityToSlideToTheNextCell || hasEnoughVelocityToSlideToThePreviousCell)

All there’s left is to perform the snap in case of a swipe.

we can use the function scrollToItem(at:at:animated:) that we used before in case there’s no swipe — but here’s the problem: we have initial velocity. we would like to continue the swipe momentum and to slow down when we reach the right step. In other words we want to use a “decay animation” on the contentOffset of the scrollView — animate a deceleration. That will be hard to calculate…

We can use a trick: a spring animation with no oscillation is actually a decay animation! The following function is a UIView spring animation:

func animate(withDuration duration: TimeInterval, delay: TimeInterval, usingSpringWithDamping dampingRatio: CGFloat, initialSpringVelocity velocity: CGFloat, options: UIViewAnimationOptions = [], animations: @escaping () -> Swift.Void, completion: ((Bool) -> Swift.Void)? = nil)

If we set the dampingRatio to 1 then we’ll have no oscillation!

Just below the line if didUseSwipeToSkipCell {add the following code:

let snapToIndex = indexOfCellBeforeDragging + (hasEnoughVelocityToSlideToTheNextCell ? 1 : -1)let toValue = collectionViewLayout.itemSize.width * CGFloat(snapToIndex)// Damping equal 1 => no oscillations => decay animation: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)

And that’s it!

You can download the finished project from github.

UPDATE: I added a simpler version of the finished project so it will be easier to adopt the concepts shown in this tutorial to your own needs.

--

--