Last active
July 10, 2020 22:15
-
-
Save DanielCardonaRojas/2b8b1ada1694289e76545ef2482ee949 to your computer and use it in GitHub Desktop.
Reference to create custom layout for UICollectionView
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// CarouselLayout.swift | |
// Spotit | |
// | |
// Created by Daniel Cardona Rojas on 10/12/19. | |
// Copyright © 2019 Daniel Cardona. All rights reserved. | |
// | |
import UIKit | |
protocol CarouselLayoutDelegate: class { | |
func carouselLayout(_ layout: CarouselLayout, willSnapTo indexPath: IndexPath) | |
} | |
class CarouselLayout: UICollectionViewLayout { | |
// MARK: Public vars | |
public weak var delegate: CarouselLayoutDelegate? | |
public var itemSpacing: CGFloat = 15 | |
public var itemWidthFactor: CGFloat = 0.75 | |
public var shrinkConstant: CGFloat = 80 | |
lazy var itemWidth: CGFloat = { | |
guard let collectionView = collectionView else { return 0 } | |
let insets = collectionView.contentInset | |
return collectionView.bounds.width * self.itemWidthFactor | |
}() | |
var viewPortCenterX: CGFloat? { | |
guard let offset = collectionView?.contentOffset, | |
let width = collectionView?.contentSize.width | |
else { | |
return nil | |
} | |
return offset.x + width / 2 | |
} | |
var viewPortRange: (CGFloat, CGFloat)? { | |
guard let offset = collectionView?.contentOffset, | |
let width = collectionView?.contentSize.width | |
else { | |
return nil | |
} | |
return (offset.x, offset.x + width) | |
} | |
// MARK: State vars | |
private var cache = [UICollectionViewLayoutAttributes]() | |
private var contentHeight: CGFloat { | |
guard let collectionView = collectionView else { return 0 } | |
let insets = collectionView.contentInset | |
return collectionView.bounds.height - (insets.bottom + insets.top) | |
} | |
fileprivate var contentWidth: CGFloat = 0 | |
// MARK: Helpers | |
private func findClosestAttributes(toXPosition xPosition: CGFloat) | |
-> UICollectionViewLayoutAttributes? | |
{ | |
let sortedByDistance = cache.sorted(by: { | |
abs($0.center.x - xPosition) < abs($1.center.x - xPosition) | |
}) | |
return sortedByDistance.first | |
} | |
private func layoutAttribute( | |
for indexPath: IndexPath, previousLayoutAttribute: UICollectionViewLayoutAttributes? | |
) -> UICollectionViewLayoutAttributes { | |
let remainingSpace = collectionView.map({ $0.bounds.width - itemWidth }) ?? 60 | |
let leftSpace = max(0, remainingSpace / 2) | |
let layoutAttribute = UICollectionViewLayoutAttributes(forCellWith: indexPath) | |
let width = itemWidth | |
let previousEndposition = previousLayoutAttribute.map({ $0.frame.origin.x + $0.frame.width } | |
) | |
let xPosition = (previousEndposition ?? 0) + leftSpace | |
let range = (xPosition, xPosition + width) | |
let vpRange = (collectionView!.contentOffset.x, collectionView!.contentOffset.x + width) | |
let overlap = min(range.1, vpRange.1) - max(range.0, vpRange.0) | |
let factor = overlap > 0 && overlap < width ? overlap / width : 0 | |
let rect = CGRect( | |
x: xPosition, | |
y: 0, | |
width: itemWidth - (shrinkConstant * (1 - factor)), | |
height: contentHeight) | |
layoutAttribute.frame = rect | |
return layoutAttribute | |
} | |
// MARK: - Overrides | |
override var collectionViewContentSize: CGSize { | |
return CGSize(width: contentWidth, height: contentHeight) | |
} | |
override func prepare() { | |
super.prepare() | |
guard let collectionView = self.collectionView else { | |
return | |
} | |
collectionView.decelerationRate = .fast | |
if collectionView.numberOfSections == 0 { | |
return | |
} | |
var previousLayoutAttributes: UICollectionViewLayoutAttributes? | |
for idx in 0..<collectionView.numberOfItems(inSection: 0) { | |
let indexPath = IndexPath(item: idx, section: 0) | |
let attribute = layoutAttribute( | |
for: indexPath, previousLayoutAttribute: previousLayoutAttributes) | |
contentWidth = max(contentWidth, attribute.frame.maxX) | |
cache.append(attribute) | |
previousLayoutAttributes = attribute | |
} | |
} | |
override func targetContentOffset( | |
forProposedContentOffset proposedContentOffset: CGPoint, | |
withScrollingVelocity velocity: CGPoint | |
) -> CGPoint { | |
guard let collectionView = collectionView else { | |
return super.targetContentOffset(forProposedContentOffset: proposedContentOffset) | |
} | |
let midX: CGFloat = collectionView.bounds.size.width / 2 | |
guard | |
let closestAttribute = findClosestAttributes( | |
toXPosition: proposedContentOffset.x + midX) | |
else { | |
return super.targetContentOffset(forProposedContentOffset: proposedContentOffset) | |
} | |
delegate?.carouselLayout(self, willSnapTo: closestAttribute.indexPath) | |
return CGPoint(x: closestAttribute.center.x - midX, y: proposedContentOffset.y) | |
} | |
override func layoutAttributesForElements(in rect: CGRect) | |
-> [UICollectionViewLayoutAttributes]? | |
{ | |
return cache.filter { attr in | |
attr.frame.intersects(rect) | |
} | |
} | |
override func layoutAttributesForItem(at indexPath: IndexPath) | |
-> UICollectionViewLayoutAttributes? | |
{ | |
cache.first(where: { $0.indexPath == indexPath }) | |
} | |
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { | |
if newBounds.size != collectionView?.bounds.size { cache.removeAll() } | |
return true | |
} | |
override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) { | |
if context.invalidateDataSourceCounts { cache.removeAll() } | |
super.invalidateLayout(with: context) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment