How to make carousel layout for UICollectionView in iOS

Issue #302

Based on AnimatedCollectionViewLayout

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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

1
2
3
4
5
6
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
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)
}
}

Comments