Search code examples
iosswiftuicollectionviewuicollectionviewlayout

How to snap horizontal paging to multi-row collection view like App Store?


I would like to replicate the paging in the multi-row App Store collection view:

enter image description here

So far I've designed it as close as possible to the way it looks, including showing a peek to the previous and next cells, but do not know how to make the paging to work so it snaps the next group of 3:

override func viewDidLoad() {
    super.viewDidLoad()

    collectionView.collectionViewLayout = MultiRowLayout(
        rowsCount: 3,
        inset: 16
    )
}

...

class MultiRowLayout: UICollectionViewFlowLayout {
    private var rowsCount: CGFloat = 0

    convenience init(rowsCount: CGFloat, spacing: CGFloat? = nil, inset: CGFloat? = nil) {
        self.init()

        self.scrollDirection = .horizontal
        self.minimumInteritemSpacing = 0
        self.rowsCount = rowsCount

        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 MultiRowLayout {

    func calculateItemSize(from bounds: CGSize) -> CGSize {
        return CGSize(
            width: bounds.width - minimumLineSpacing * 2 - sectionInset.left,
            height: bounds.height / rowsCount
        )
    }
}

Unfortunately, the native isPagingEnabled flag on UICollectionView only works if the cell is 100% width of the collection view, so the user wouldn’t get a peek and the previous and next cell.

I have a working snap paging functionality but only for a single item per page, not this 3-row kind of collection. Can someone help make the snap paging work for the grouped rows instead of for a single item per page?


Solution

  • There is no reason to subclass UICollectionViewFlowLayout just for this behavior.

    UICollectionView is a subclass of UIScrollView, so its delegate protocol UICollectionViewDelegate is a subtype of UIScrollViewDelegate. This means you can implement any of UIScrollViewDelegate’s methods in your collection view’s delegate.

    In your collection view’s delegate, implement scrollViewWillEndDragging(_:withVelocity:targetContentOffset:) to round the target content offset to the top left corner of the nearest column of cells.

    Here's an example implementation:

    override func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        let layout = collectionViewLayout as! UICollectionViewFlowLayout
        let bounds = scrollView.bounds
        let xTarget = targetContentOffset.pointee.x
    
        // This is the max contentOffset.x to allow. With this as contentOffset.x, the right edge of the last column of cells is at the right edge of the collection view's frame.
        let xMax = scrollView.contentSize.width - scrollView.bounds.width
    
        if abs(velocity.x) <= snapToMostVisibleColumnVelocityThreshold {
            let xCenter = scrollView.bounds.midX
            let poses = layout.layoutAttributesForElements(in: bounds) ?? []
            // Find the column whose center is closest to the collection view's visible rect's center.
            let x = poses.min(by: { abs($0.center.x - xCenter) < abs($1.center.x - xCenter) })?.frame.origin.x ?? 0
            targetContentOffset.pointee.x = x
        } else if velocity.x > 0 {
            let poses = layout.layoutAttributesForElements(in: CGRect(x: xTarget, y: 0, width: bounds.size.width, height: bounds.size.height)) ?? []
            // Find the leftmost column beyond the current position.
            let xCurrent = scrollView.contentOffset.x
            let x = poses.filter({ $0.frame.origin.x > xCurrent}).min(by: { $0.center.x < $1.center.x })?.frame.origin.x ?? xMax
            targetContentOffset.pointee.x = min(x, xMax)
        } else {
            let poses = layout.layoutAttributesForElements(in: CGRect(x: xTarget - bounds.size.width, y: 0, width: bounds.size.width, height: bounds.size.height)) ?? []
            // Find the rightmost column.
            let x = poses.max(by: { $0.center.x < $1.center.x })?.frame.origin.x ?? 0
            targetContentOffset.pointee.x = max(x, 0)
        }
    }
    
    // Velocity is measured in points per millisecond.
    private var snapToMostVisibleColumnVelocityThreshold: CGFloat { return 0.3 }
    

    Result:

    demo

    You can find the full source code for my test project here: https://github.com/mayoff/multiRowSnapper