onmyway133 / blog

🍁 What you don't know is what you haven't learned
https://onmyway133.com/
MIT License
680 stars 33 forks source link

How to make carousel layout for UICollectionView in iOS #302

Open onmyway133 opened 5 years ago

onmyway133 commented 5 years ago

Based on AnimatedCollectionViewLayout

final class CarouselLayout: UICollectionViewFlowLayout {
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        guard let attributes = super.layoutAttributesForElements(in: rect) else { return nil }
        guard let collectionView = collectionView else { return nil }
        return attributes.map({ transform(collectionView: collectionView, attribute: $0) })
    }

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

    private func transform(collectionView: UICollectionView, attribute: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
        let a = attribute
        let width = collectionView.frame.size.width
        let itemOffset = a.center.x - collectionView.contentOffset.x
        let middleOffset = (itemOffset / width) - 0.5

        change(
            width: collectionView.frame.size.width,
            attribute: attribute,
            middleOffset: middleOffset
        )

        return attribute
    }

    private func change(width: CGFloat, attribute: UICollectionViewLayoutAttributes, middleOffset: CGFloat) {
        let alpha: CGFloat = 0.8
        let itemSpacing: CGFloat = 0.21
        let scale: CGFloat = 1.0

        let scaleFactor = scale - 0.1 * abs(middleOffset)
        let scaleTransform = CGAffineTransform(scaleX: scaleFactor, y: scaleFactor)

        let translationX = -(width * itemSpacing * middleOffset)
        let translationTransform = CGAffineTransform(translationX: translationX, y: 0)

        attribute.alpha = 1.0 - abs(middleOffset) + alpha
        attribute.transform = translationTransform.concatenating(scaleTransform)
    }
}

How to use

let layout = CarouselLayout()

layout.scrollDirection = .horizontal
layout.sectionInset = .zero
layout.minimumInteritemSpacing = 0
layout.minimumLineSpacing = 0

We can inset cell content and use let scale: CGFloat = 1.0 to avoid scaling down center cell

Based on CityCollectionViewFlowLayout

import UIKit

class CityCollectionViewFlowLayout: UICollectionViewFlowLayout {

    fileprivate var lastCollectionViewSize: CGSize = CGSize.zero

    var scaleOffset: CGFloat = 200
    var scaleFactor: CGFloat = 0.9
    var alphaFactor: CGFloat = 0.3
    var lineSpacing: CGFloat = 25.0

    required init?(coder _: NSCoder) {
        fatalError()
    }

    init(itemSize: CGSize) {
        super.init()
        self.itemSize = itemSize
        minimumLineSpacing = lineSpacing
        scrollDirection = .horizontal
    }

    func setItemSize(itemSize: CGSize) {
        self.itemSize = itemSize
    }

    override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
        super.invalidateLayout(with: context)

        guard let collectionView = self.collectionView else { return }

        if collectionView.bounds.size != lastCollectionViewSize {
            configureContentInset()
            lastCollectionViewSize = collectionView.bounds.size
        }
    }

    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
        guard let collectionView = self.collectionView else {
            return proposedContentOffset
        }

        let proposedRect = CGRect(x: proposedContentOffset.x, y: 0, width: collectionView.bounds.width, height: collectionView.bounds.height)
        guard let layoutAttributes = self.layoutAttributesForElements(in: proposedRect) else {
            return proposedContentOffset
        }

        var candidateAttributes: UICollectionViewLayoutAttributes?
        let proposedContentOffsetCenterX = proposedContentOffset.x + collectionView.bounds.width / 2

        for attributes in layoutAttributes {
            if attributes.representedElementCategory != .cell {
                continue
            }

            if candidateAttributes == nil {
                candidateAttributes = attributes
                continue
            }

            if abs(attributes.center.x - proposedContentOffsetCenterX) < abs(candidateAttributes!.center.x - proposedContentOffsetCenterX) {
                candidateAttributes = attributes
            }
        }

        guard let aCandidateAttributes = candidateAttributes else {
            return proposedContentOffset
        }

        var newOffsetX = aCandidateAttributes.center.x - collectionView.bounds.size.width / 2
        let offset = newOffsetX - collectionView.contentOffset.x

        if (velocity.x < 0 && offset > 0) || (velocity.x > 0 && offset < 0) {
            let pageWidth = itemSize.width + minimumLineSpacing
            newOffsetX += velocity.x > 0 ? pageWidth : -pageWidth
        }

        return CGPoint(x: newOffsetX, y: proposedContentOffset.y)
    }

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

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        guard let collectionView = self.collectionView,
            let superAttributes = super.layoutAttributesForElements(in: rect) else {
                return super.layoutAttributesForElements(in: rect)
        }

        let contentOffset = collectionView.contentOffset
        let size = collectionView.bounds.size

        let visibleRect = CGRect(x: contentOffset.x, y: contentOffset.y, width: size.width, height: size.height)
        let visibleCenterX = visibleRect.midX

        guard case let newAttributesArray as [UICollectionViewLayoutAttributes] = NSArray(array: superAttributes, copyItems: true) else {
            return nil
        }

        newAttributesArray.forEach {
            let distanceFromCenter = visibleCenterX - $0.center.x
            let absDistanceFromCenter = min(abs(distanceFromCenter), self.scaleOffset)
            let scale = absDistanceFromCenter * (self.scaleFactor - 1) / self.scaleOffset + 1
            $0.transform3D = CATransform3DScale(CATransform3DIdentity, scale, scale, 1)

            let alpha = absDistanceFromCenter * (self.alphaFactor - 1) / self.scaleOffset + 1
            $0.alpha = alpha
        }

        return newAttributesArray
    }

    func configureContentInset() {
        guard let collectionView = self.collectionView else {
            return
        }

        let inset = collectionView.bounds.size.width / 2 - itemSize.width / 2
        collectionView.contentInset = UIEdgeInsets.init(top: 0, left: inset, bottom: 0, right: inset)
        collectionView.contentOffset = CGPoint(x: -inset, y: 0)
    }

    func resetContentInset() {
        guard let collectionView = self.collectionView else {
            return
        }

        collectionView.contentInset = UIEdgeInsets.init(top: 0, left: 0, bottom: 0, right: 0)
    }
}
mrbodich commented 4 years ago

When I tap on cell, how to zoom it fullscreen? Either single cell or all cells... I need to zoom fullscreen and then back to carousel.