I need a collection view to page through cells and center it like the App Store, where a portion of the previous and next cells look like this:

enter image description here

The native isPagingEnabled flag would be great if it had an option to center on the cell. However, making the cell span the full width of the collection view doesn't show previous/next cells, and making the cell width smaller doesn't snap to center when paging.

There are tons of hacks, articles, and suggestions out there that I've tried. Many were overcomplicated or a horrible UX. I finally find a good balance with this example and adjusted the code.

This is the code and scroll delegate event to make this work using a flow layout:

class HomeViewController: UIViewController {

    @IBOutlet weak var collectionView: UICollectionView!

    override func viewDidLoad() {
        super.viewDidLoad()

        collectionView.collectionViewLayout = SnapPagingLayout(
            centerPosition: true,
            peekWidth: 40,
            spacing: 20,
            inset: 16
        )
    }

    ...
}

extension HomeViewController: UICollectionViewDelegate {

    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        guard let layout = collectionView.collectionViewLayout as? SnapPagingLayout else { return }
        layout.willBeginDragging()
    }

    func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        guard let layout = collectionView.collectionViewLayout as? SnapPagingLayout else { return }
        layout.willEndDragging(withVelocity: velocity, targetContentOffset: targetContentOffset)
    }

}

//////////

class SnapPagingLayout: UICollectionViewFlowLayout {
    private var centerPosition = true
    private var peekWidth: CGFloat = 0
    private var indexOfCellBeforeDragging = 0

    convenience init(centerPosition: Bool = true, peekWidth: CGFloat = 40, spacing: CGFloat? = nil, inset: CGFloat? = nil) {
        self.init()

        self.scrollDirection = .horizontal
        self.centerPosition = centerPosition
        self.peekWidth = peekWidth

        if let spacing = spacing {
            self.minimumLineSpacing = spacing
        }

        if let inset = inset {
            self.sectionInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset)
        }
    }

    override func prepare() {
        super.prepare()

        guard let collectionView = collectionView else { return }
        self.itemSize = calculateItemSize(from: collectionView.bounds.size)
    }

    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        guard let collectionView = collectionView,
            !newBounds.size.equalTo(collectionView.bounds.size) else {
                return false
        }

        itemSize = calculateItemSize(from: collectionView.bounds.size)
        return true
    }
}

private extension SnapPagingLayout {

    func calculateItemSize(from bounds: CGSize) -> CGSize {
        return CGSize(
            width: bounds.width - peekWidth * 2,
            height: bounds.height
        )
    }

    func indexOfMajorCell() -> Int {
        guard let collectionView = collectionView else { return 0 }

        let proportionalOffset = collectionView.contentOffset.x
            / (itemSize.width + minimumLineSpacing)

        return Int(round(proportionalOffset))
    }
}

extension SnapPagingLayout {

    func willBeginDragging() {
        indexOfCellBeforeDragging = indexOfMajorCell()
    }

    func willEndDragging(withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        guard let collectionView = collectionView else { return }

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

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

        guard let dataSourceCount = collectionView.dataSource?.collectionView(collectionView, numberOfItemsInSection: 0),
            dataSourceCount > 0 else {
                return
        }

        // Calculate conditions
        let swipeVelocityThreshold: CGFloat = 0.5 // After some trail and error
        let hasEnoughVelocityToSlideToTheNextCell = indexOfCellBeforeDragging + 1 < dataSourceCount && velocity.x > swipeVelocityThreshold
        let hasEnoughVelocityToSlideToThePreviousCell = indexOfCellBeforeDragging - 1 >= 0 && velocity.x < -swipeVelocityThreshold
        let majorCellIsTheCellBeforeDragging = indexOfMajorCell == indexOfCellBeforeDragging
        let didUseSwipeToSkipCell = majorCellIsTheCellBeforeDragging
            && (hasEnoughVelocityToSlideToTheNextCell || hasEnoughVelocityToSlideToThePreviousCell)

        guard didUseSwipeToSkipCell else {
            // Better way to scroll to a cell
            collectionView.scrollToItem(
                at: IndexPath(row: indexOfMajorCell, section: 0),
                at: centerPosition ? .centeredHorizontally : .left, // TODO: Left ignores inset
                animated: true
            )

            return
        }

        let snapToIndex = indexOfCellBeforeDragging + (hasEnoughVelocityToSlideToTheNextCell ? 1 : -1)
        var toValue = CGFloat(snapToIndex) * (itemSize.width + minimumLineSpacing)

        if centerPosition {
            // Back up a bit to center
            toValue = toValue - peekWidth + sectionInset.left
        }

        // Damping equal 1 => no oscillations => decay animation
        UIView.animate(
            withDuration: 0.3,
            delay: 0,
            usingSpringWithDamping: 1,
            initialSpringVelocity: velocity.x,
            options: .allowUserInteraction,
            animations: {
                collectionView.contentOffset = CGPoint(x: toValue, y: 0)
                collectionView.layoutIfNeeded()
            },
            completion: nil
        )
    }
}

It doesn’t feel as smooth as the App Store (especially when scrolling fast), but it’s almost there. Do you have any suggestions or advice on how to simplify this or improve it?

  • 1
    Is there any chance that you can provide a complete downloadable project, similar to the one that you referenced? – Martin R Jun 27 at 6:37
  • Yes there's a refactor_layout branch: bit.ly/2Ixjqqi – TruMan1 Jun 27 at 15:01

Your Answer

 

By clicking "Post Your Answer", you acknowledge that you have read our updated terms of service, privacy policy and cookie policy, and that your continued use of the website is subject to these policies.

Browse other questions tagged or ask your own question.