Diogo Antunes
Diogo Antunes

Reputation: 2291

UICollectionView horizontal paging layout

Hy, I'm trying to achieve this UI element, that it seems (to me) like an horizontal UIPickerView. Here is an example GIF from when creating a "memoji" on iOS:

Example GIF

Example Picture

I have been trying to accomplish this with UICollectionView and a custom UICollectionViewFlowLayout. But without much luck.

What I tried so far is using

func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint

To stop the scrolling on each cell, thus giving it a sense of paging. But in order to do that I actually have to make the

var collectionViewContentSize: CGSize

Return a way higher content size than it actually exists, otherwise it would just bounce the collectionView and nothing would snap into place no matter what I returned on the previous function.

I also tried using

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

To set the collectionView.contentOffset but that was causing weird jumps in the animation and again it was not changing this properly. Besides the paging per cell I would like to achieve whats on that UI element, a small Haptic Feedback on each scroll when passing trough the elements and the fade in and out of left and right elements on the border. If anyone could point me in the right direction, maybe UICollectionView is not the way to go? I would appreciate a lot. Thank you

Upvotes: 0

Views: 677

Answers (1)

Diogo Antunes
Diogo Antunes

Reputation: 2291

I was able to achieve this using a custom UICollectionViewFlowLayout

final class PaginatedCollectionViewFlow: UICollectionViewFlowLayout {

    /* Distance from the midle to the other side of the screen */
    var availableDistance: CGFloat = 0.0


    var midX: CGFloat = 0
    var lastElementIndex = 0

    let maxAngle = CGFloat(-60.0.degree2Rad)

    override func prepare () {
        minimumInteritemSpacing = 40.0
        scrollDirection = .horizontal
    }

    /* This should be cached */
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        guard
            let layoutAttributes = super.layoutAttributesForElements(in: rect),
            let cv = collectionView else { return nil }

        /* Size of the collectionView */
        let visibleRect = CGRect(origin: cv.contentOffset, size: cv.bounds.size)

        let attributes: [UICollectionViewLayoutAttributes] = layoutAttributes.compactMap { attribute in
            guard let copy = attribute.copy() as? UICollectionViewLayoutAttributes else { return nil }

            /* Distance from the middle of the screen to the middle of the cell attributes */
            let distance = visibleRect.midX - attribute.center.x

            /* Normalize the distance between [0, 1] */
            let normalizedDistance = abs(distance / availableDistance)

            /* Rotate the cell and apply alpha accordingly to the maximum distance from the center */
            copy.alpha = 1.0 - normalizedDistance
            copy.transform3D = CATransform3DMakeRotation(maxAngle * normalizedDistance, 0, 1, 0)

            return copy
        }

        return attributes
    }

    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        return true
    }
}

Make sure to set the UICollectionViewFlowLayout parameters after adding the UICollectionView:

guard let flow = collectionView.collectionViewLayout as? PaginatedCollectionViewFlow else { return }

       /* Distance from the middle to the other side of the screen */
       flow.availableDistance = floor(view.bounds.width / 2.0)

       /* Middle of the screen */
       flow.midX = ceil(view.bounds.midX)

       /* Index of the last element in the collectionView */
       flow.lastElementIndex = vm.numberOfItems - 1

       /* Left and Right Insets */
       flow.sectionInset.left = flow.midX - 30.0
       flow.sectionInset.right = flow.midX - 30.0

And finally after conforming to UICollectionViewDelegate to get the UIScrollView delegate methods:

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    scrollToPosition(scrollView: scrollView)
}

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    guard !decelerate else { return }

    scrollToPosition(scrollView: scrollView)
}

internal func scrollToPosition(scrollView: UIScrollView) {
    guard let ip = indexPathForCenterCell else { return }

    scrollToIndex(ip.row, animated: true)
}

internal func scrollToIndex(_ index: Int, animated: Bool) {
    let ip = IndexPath(item: index, section: 0)

    guard let attributes = collectionView.layoutAttributesForItem(at: ip) else { return }

    let halfWidth = collectionView.frame.width / CGFloat(2.0)
    let offset = CGPoint(x: attributes.frame.midX - halfWidth, y: 0)

    collectionView.setContentOffset(offset, animated: animated)

    guard let cell = collectionView.cellForItem(at: ip) else { return }

    feedbackGenerator.selectionChanged()
    cell.isHighlighted = true
    collectionView.visibleCells.filter { $0 != cell }.forEach { $0.isHighlighted = false }
}

internal var indexPathForCenterCell: IndexPath? {
    let point = collectionView.convert(collectionView.center, from: collectionView.superview)

    guard let indexPath = collectionView.indexPathForItem(at: point) else { return collectionView.indexPathsForVisibleItems.first }

    return indexPath
}

/* Gets the CGSize based of a maximum size available for the provided String */
func sizeFor(text: String) -> CGSize {
    guard let font = UIFont(font: .sanFranciscoSemiBold, size: 15.0) else { return .zero }

    let textNS = text as NSString
    let maxSize = CGSize(width: collectionView.frame.width / 2, height: collectionView.frame.height)
    let frame = textNS.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font : font], context: nil)

    return frame.size
}

This provided Pagination of the UICollectionViewCells while "snapping" it to the nearest cell and also using UISelectionFeedbackGenerator to generate haptic feedback. Hope this helps someone with the same problem I had.

Upvotes: 1

Related Questions