#StackBounty: #swift #ios #user-interface Page and center UICollectionView like App Store

Bounty: 50

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:

class HomeViewController: UIViewController {

    @IBOutlet weak var collectionView: UICollectionView!
    private var indexOfCellBeforeDragging = 0

    override func viewDidLoad() {
        super.viewDidLoad()

        let layout = UICollectionViewFlowLayout()
        let width = view.frame.width
        let height: CGFloat = 275
        let inset: CGFloat = 30
        layout.itemSize = CGSize(width: width - inset * 2, height: height)
        layout.sectionInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset)
        layout.minimumLineSpacing = 0
        layout.scrollDirection = .horizontal
        collectionView.collectionViewLayout = layout
        collectionView.frame = CGRect(x: 0, y: 0, width: width, height: height)
    }

    ...
}

extension HomeViewController: UICollectionViewDelegate {

    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        indexOfCellBeforeDragging = indexOfMajorCell(for: collectionView)
    }

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

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

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

        // Calculate conditions
        let dataSourceCount = collectionView(collectionView!, numberOfItemsInSection: 0)
        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
            return collectionView.scrollToItem(
                at: IndexPath(row: indexOfMajorCell, section: 0),
                at: .centeredHorizontally,
                animated: true
            )
        }

        let snapToIndex = indexOfCellBeforeDragging + (hasEnoughVelocityToSlideToTheNextCell ? 1 : -1)
        let toValue = layout.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
        )
    }

    private func indexOfMajorCell(for collectionView: UICollectionView) -> Int {
        guard let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout else { return 0 }
        let itemWidth = layout.itemSize.width
        let proportionalOffset = collectionView.contentOffset.x / itemWidth
        return Int(round(proportionalOffset))
    }
}

Do you have any suggestions or advice on how to simplify this or make it reusable? I don’t like how everything is not encapsulated in the extension (i.e., the private property indexOfCellBeforeDragging is outside). Also, any issues that come to mind?

UPDATE:

I found a better way to encapsulate by subclassing a UICollectionViewFlowLayout:

class HomeViewController: UIViewController {

    @IBOutlet weak var collectionView: UICollectionView!

    override func viewDidLoad() {
        super.viewDidLoad()

        let width = view.frame.width
        let height: CGFloat = 275
        collectionView.collectionViewLayout = SnapPagingLayout(width: width, height: height, inset: 30)
        collectionView.frame = CGRect(x: 0, y: 0, width: width, height: height + 40)
    }

    ...
}

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 indexOfCellBeforeDragging = 0

    convenience init(width: CGFloat, height: CGFloat, inset: CGFloat) {
        self.init()

        self.itemSize = CGSize(width: width - inset * 2, height: height)
        self.sectionInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset)
        self.minimumLineSpacing = 0
        self.scrollDirection = .horizontal
    }
}

private extension SnapPagingLayout {

    func indexOfMajorCell() -> Int {
        guard let collectionView = collectionView else { return 0 }
        let proportionalOffset = collectionView.contentOffset.x / itemSize.width
        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
            return collectionView.scrollToItem(
                at: IndexPath(row: indexOfMajorCell, section: 0),
                at: .centeredHorizontally,
                animated: true
            )
        }

        let snapToIndex = indexOfCellBeforeDragging + (hasEnoughVelocityToSlideToTheNextCell ? 1 : -1)
        let toValue = 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: {
                collectionView.contentOffset = CGPoint(x: toValue, y: 0)
                collectionView.layoutIfNeeded()
            },
            completion: nil
        )
    }
}

Do you have any advice or suggestions on optimizing or a better UX? It doesn’t feel as smooth as the App Store (especially when scrolling fast), but it’s almost there. Also, it misbehaves in landscape or in iPad; I think there needs to be some kind of cancellation if something like more than 1 cell can fit on the screen!


Get this bounty!!!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.