|
9 | 9 | import UIKit |
10 | 10 |
|
11 | 11 | public extension UICollectionView { |
12 | | - /// A convenient way to create a UICollectionView and configue it with a CenteredCollectionViewFlowLayout. |
13 | | - /// |
14 | | - /// - Parameters: |
15 | | - /// - frame: The frame rectangle for the collection view, measured in points. The origin of the frame is relative to the superview in which you plan to add it. This frame is passed to the superclass during initialization. |
16 | | - /// - centeredCollectionViewFlowLayout: The `CenteredCollectionViewFlowLayout` for the `UICollectionView` to be configured with. |
| 12 | + /// A convenient way to create a UICollectionView and configue it with a CenteredCollectionViewFlowLayout. |
| 13 | + /// |
| 14 | + /// - Parameters: |
| 15 | + /// - frame: The frame rectangle for the collection view, measured in points. The origin of the frame is relative to the superview in which you plan to add it. This frame is passed to the superclass during initialization. |
| 16 | + /// - centeredCollectionViewFlowLayout: The `CenteredCollectionViewFlowLayout` for the `UICollectionView` to be configured with. |
17 | 17 | convenience init(frame: CGRect = .zero, centeredCollectionViewFlowLayout: CenteredCollectionViewFlowLayout) { |
18 | | - self.init(frame: frame, collectionViewLayout: centeredCollectionViewFlowLayout) |
19 | | - decelerationRate = UIScrollView.DecelerationRate.fast |
20 | | - } |
| 18 | + self.init(frame: frame, collectionViewLayout: centeredCollectionViewFlowLayout) |
| 19 | + decelerationRate = UIScrollView.DecelerationRate.fast |
| 20 | + } |
21 | 21 | } |
22 | 22 |
|
23 | 23 | /// A `UICollectionViewFlowLayout` that _pages_ and keeps its cells centered, resulting in the _"carousel effect"_ 🎡 |
24 | 24 | open class CenteredCollectionViewFlowLayout: UICollectionViewFlowLayout { |
25 | | - private var lastCollectionViewSize: CGSize = CGSize.zero |
26 | | - private var lastScrollDirection: UICollectionView.ScrollDirection! |
27 | | - private var lastItemSize: CGSize = CGSize.zero |
28 | | - var pageWidth: CGFloat { |
29 | | - switch scrollDirection { |
30 | | - case .horizontal: |
31 | | - return itemSize.width + minimumLineSpacing |
32 | | - case .vertical: |
33 | | - return itemSize.height + minimumLineSpacing |
34 | | - |
| 25 | + private var lastCollectionViewSize: CGSize = CGSize.zero |
| 26 | + private var lastScrollDirection: UICollectionView.ScrollDirection! |
| 27 | + private var lastItemSize: CGSize = CGSize.zero |
| 28 | + var pageWidth: CGFloat { |
| 29 | + switch scrollDirection { |
| 30 | + case .horizontal: |
| 31 | + return itemSize.width + minimumLineSpacing |
| 32 | + case .vertical: |
| 33 | + return itemSize.height + minimumLineSpacing |
35 | 34 | default: |
36 | 35 | return 0 |
37 | 36 | } |
38 | | - } |
39 | | - |
40 | | - /// Calculates the current centered page. |
41 | | - public var currentCenteredPage: Int? { |
42 | | - guard let collectionView = collectionView else { return nil } |
43 | | - let currentCenteredPoint = CGPoint(x: collectionView.contentOffset.x + collectionView.bounds.width/2, y: collectionView.contentOffset.y + collectionView.bounds.height/2) |
44 | | - |
45 | | - return collectionView.indexPathForItem(at: currentCenteredPoint)?.row |
46 | | - } |
47 | | - |
48 | | - public override init() { |
49 | | - super.init() |
50 | | - scrollDirection = .horizontal |
51 | | - lastScrollDirection = scrollDirection |
52 | | - } |
53 | | - |
54 | | - required public init?(coder aDecoder: NSCoder) { |
55 | | - super.init(coder: aDecoder) |
56 | | - scrollDirection = .horizontal |
57 | | - lastScrollDirection = scrollDirection |
58 | | - } |
59 | | - |
60 | | - override open func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) { |
61 | | - super.invalidateLayout(with: context) |
62 | | - guard let collectionView = collectionView else { return } |
63 | | - |
64 | | - // invalidate layout to center first and last |
65 | | - let currentCollectionViewSize = collectionView.bounds.size |
66 | | - if !currentCollectionViewSize.equalTo(lastCollectionViewSize) || lastScrollDirection != scrollDirection || lastItemSize != itemSize { |
67 | | - switch scrollDirection { |
68 | | - case .horizontal: |
69 | | - let inset = (currentCollectionViewSize.width - itemSize.width) / 2 |
70 | | - collectionView.contentInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset) |
71 | | - collectionView.contentOffset = CGPoint(x: -inset, y: 0) |
72 | | - case .vertical: |
73 | | - let inset = (currentCollectionViewSize.height - itemSize.height) / 2 |
74 | | - collectionView.contentInset = UIEdgeInsets(top: inset, left: 0, bottom: inset, right: 0) |
75 | | - collectionView.contentOffset = CGPoint(x: 0, y: -inset) |
76 | | - |
| 37 | + } |
| 38 | + |
| 39 | + /// Calculates the current centered page. |
| 40 | + public var currentCenteredPage: Int? { |
| 41 | + guard let collectionView = collectionView else { return nil } |
| 42 | + let currentCenteredPoint = CGPoint(x: collectionView.contentOffset.x + collectionView.bounds.width/2, y: collectionView.contentOffset.y + collectionView.bounds.height/2) |
| 43 | + |
| 44 | + return collectionView.indexPathForItem(at: currentCenteredPoint)?.row |
| 45 | + } |
| 46 | + |
| 47 | + public override init() { |
| 48 | + super.init() |
| 49 | + scrollDirection = .horizontal |
| 50 | + lastScrollDirection = scrollDirection |
| 51 | + } |
| 52 | + |
| 53 | + required public init?(coder aDecoder: NSCoder) { |
| 54 | + super.init(coder: aDecoder) |
| 55 | + scrollDirection = .horizontal |
| 56 | + lastScrollDirection = scrollDirection |
| 57 | + } |
| 58 | + |
| 59 | + override open func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) { |
| 60 | + super.invalidateLayout(with: context) |
| 61 | + guard let collectionView = collectionView else { return } |
| 62 | + |
| 63 | + // invalidate layout to center first and last |
| 64 | + let currentCollectionViewSize = collectionView.bounds.size |
| 65 | + if !currentCollectionViewSize.equalTo(lastCollectionViewSize) || lastScrollDirection != scrollDirection || lastItemSize != itemSize { |
| 66 | + switch scrollDirection { |
| 67 | + case .horizontal: |
| 68 | + let inset = (currentCollectionViewSize.width - itemSize.width) / 2 |
| 69 | + collectionView.contentInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset) |
| 70 | + collectionView.contentOffset = CGPoint(x: -inset, y: 0) |
| 71 | + case .vertical: |
| 72 | + let inset = (currentCollectionViewSize.height - itemSize.height) / 2 |
| 73 | + collectionView.contentInset = UIEdgeInsets(top: inset, left: 0, bottom: inset, right: 0) |
| 74 | + collectionView.contentOffset = CGPoint(x: 0, y: -inset) |
77 | 75 | default: |
78 | 76 | collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) |
79 | 77 | collectionView.contentOffset = .zero |
80 | | - } |
81 | | - lastCollectionViewSize = currentCollectionViewSize |
82 | | - lastScrollDirection = scrollDirection |
83 | | - lastItemSize = itemSize |
84 | | - } |
85 | | - } |
86 | | - |
87 | | - override open func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint { |
88 | | - guard let collectionView = collectionView else { return proposedContentOffset } |
89 | | - |
90 | | - let proposedRect: CGRect = determineProposedRect(collectionView: collectionView, proposedContentOffset: proposedContentOffset) |
91 | | - |
92 | | - guard let layoutAttributes = layoutAttributesForElements(in: proposedRect), |
93 | | - let candidateAttributesForRect = attributesForRect( |
94 | | - collectionView: collectionView, |
95 | | - layoutAttributes: layoutAttributes, |
96 | | - proposedContentOffset: proposedContentOffset |
97 | | - ) else { return proposedContentOffset } |
98 | | - |
99 | | - var newOffset: CGFloat |
100 | | - let offset: CGFloat |
101 | | - switch scrollDirection { |
102 | | - case .horizontal: |
103 | | - newOffset = candidateAttributesForRect.center.x - collectionView.bounds.size.width / 2 |
104 | | - offset = newOffset - collectionView.contentOffset.x |
105 | | - |
106 | | - if (velocity.x < 0 && offset > 0) || (velocity.x > 0 && offset < 0) { |
107 | | - let pageWidth = itemSize.width + minimumLineSpacing |
108 | | - newOffset += velocity.x > 0 ? pageWidth : -pageWidth |
109 | | - } |
110 | | - return CGPoint(x: newOffset, y: proposedContentOffset.y) |
111 | | - |
112 | | - case .vertical: |
113 | | - newOffset = candidateAttributesForRect.center.y - collectionView.bounds.size.height / 2 |
114 | | - offset = newOffset - collectionView.contentOffset.y |
115 | | - |
116 | | - if (velocity.y < 0 && offset > 0) || (velocity.y > 0 && offset < 0) { |
117 | | - let pageHeight = itemSize.height + minimumLineSpacing |
118 | | - newOffset += velocity.y > 0 ? pageHeight : -pageHeight |
119 | | - } |
120 | | - return CGPoint(x: proposedContentOffset.x, y: newOffset) |
| 78 | + } |
| 79 | + lastCollectionViewSize = currentCollectionViewSize |
| 80 | + lastScrollDirection = scrollDirection |
| 81 | + lastItemSize = itemSize |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + override open func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint { |
| 86 | + guard let collectionView = collectionView else { return proposedContentOffset } |
| 87 | + |
| 88 | + let proposedRect: CGRect = determineProposedRect(collectionView: collectionView, proposedContentOffset: proposedContentOffset) |
| 89 | + |
| 90 | + guard let layoutAttributes = layoutAttributesForElements(in: proposedRect), |
| 91 | + let candidateAttributesForRect = attributesForRect( |
| 92 | + collectionView: collectionView, |
| 93 | + layoutAttributes: layoutAttributes, |
| 94 | + proposedContentOffset: proposedContentOffset |
| 95 | + ) else { return proposedContentOffset } |
| 96 | + |
| 97 | + var newOffset: CGFloat |
| 98 | + let offset: CGFloat |
| 99 | + switch scrollDirection { |
| 100 | + case .horizontal: |
| 101 | + newOffset = candidateAttributesForRect.center.x - collectionView.bounds.size.width / 2 |
| 102 | + offset = newOffset - collectionView.contentOffset.x |
| 103 | + |
| 104 | + if (velocity.x < 0 && offset > 0) || (velocity.x > 0 && offset < 0) { |
| 105 | + let pageWidth = itemSize.width + minimumLineSpacing |
| 106 | + newOffset += velocity.x > 0 ? pageWidth : -pageWidth |
| 107 | + } |
| 108 | + return CGPoint(x: newOffset, y: proposedContentOffset.y) |
| 109 | + |
| 110 | + case .vertical: |
| 111 | + newOffset = candidateAttributesForRect.center.y - collectionView.bounds.size.height / 2 |
| 112 | + offset = newOffset - collectionView.contentOffset.y |
| 113 | + |
| 114 | + if (velocity.y < 0 && offset > 0) || (velocity.y > 0 && offset < 0) { |
| 115 | + let pageHeight = itemSize.height + minimumLineSpacing |
| 116 | + newOffset += velocity.y > 0 ? pageHeight : -pageHeight |
| 117 | + } |
| 118 | + return CGPoint(x: proposedContentOffset.x, y: newOffset) |
121 | 119 |
|
122 | 120 | default: |
123 | 121 | return .zero |
124 | | - } |
125 | | - } |
126 | | - |
127 | | - /// Programmatically scrolls to a page at a specified index. |
128 | | - /// |
129 | | - /// - Parameters: |
130 | | - /// - index: The index of the page to scroll to. |
131 | | - /// - animated: Whether the scroll should be performed animated. |
132 | | - public func scrollToPage(index: Int, animated: Bool) { |
133 | | - guard let collectionView = collectionView else { return } |
134 | | - |
135 | | - let proposedContentOffset: CGPoint |
136 | | - let shouldAnimate: Bool |
137 | | - switch scrollDirection { |
138 | | - case .horizontal: |
139 | | - let pageOffset = CGFloat(index) * pageWidth - collectionView.contentInset.left |
140 | | - proposedContentOffset = CGPoint(x: pageOffset, y: collectionView.contentOffset.y) |
141 | | - shouldAnimate = abs(collectionView.contentOffset.x - pageOffset) > 1 ? animated : false |
142 | | - case .vertical: |
143 | | - let pageOffset = CGFloat(index) * pageWidth - collectionView.contentInset.top |
144 | | - proposedContentOffset = CGPoint(x: collectionView.contentOffset.x, y: pageOffset) |
145 | | - shouldAnimate = abs(collectionView.contentOffset.y - pageOffset) > 1 ? animated : false |
146 | | - |
| 122 | + } |
| 123 | + } |
| 124 | + |
| 125 | + /// Programmatically scrolls to a page at a specified index. |
| 126 | + /// |
| 127 | + /// - Parameters: |
| 128 | + /// - index: The index of the page to scroll to. |
| 129 | + /// - animated: Whether the scroll should be performed animated. |
| 130 | + public func scrollToPage(index: Int, animated: Bool) { |
| 131 | + guard let collectionView = collectionView else { return } |
| 132 | + |
| 133 | + let proposedContentOffset: CGPoint |
| 134 | + let shouldAnimate: Bool |
| 135 | + switch scrollDirection { |
| 136 | + case .horizontal: |
| 137 | + let pageOffset = CGFloat(index) * pageWidth - collectionView.contentInset.left |
| 138 | + proposedContentOffset = CGPoint(x: pageOffset, y: collectionView.contentOffset.y) |
| 139 | + shouldAnimate = abs(collectionView.contentOffset.x - pageOffset) > 1 ? animated : false |
| 140 | + case .vertical: |
| 141 | + let pageOffset = CGFloat(index) * pageWidth - collectionView.contentInset.top |
| 142 | + proposedContentOffset = CGPoint(x: collectionView.contentOffset.x, y: pageOffset) |
| 143 | + shouldAnimate = abs(collectionView.contentOffset.y - pageOffset) > 1 ? animated : false |
147 | 144 | default: |
148 | 145 | proposedContentOffset = .zero |
149 | 146 | shouldAnimate = false |
150 | | - } |
151 | | - collectionView.setContentOffset(proposedContentOffset, animated: shouldAnimate) |
152 | | - } |
| 147 | + } |
| 148 | + collectionView.setContentOffset(proposedContentOffset, animated: shouldAnimate) |
| 149 | + } |
153 | 150 | } |
154 | 151 |
|
155 | 152 | private extension CenteredCollectionViewFlowLayout { |
156 | | - |
157 | | - func determineProposedRect(collectionView: UICollectionView, proposedContentOffset: CGPoint) -> CGRect { |
158 | | - let size = collectionView.bounds.size |
159 | | - let origin: CGPoint |
160 | | - switch scrollDirection { |
161 | | - case .horizontal: |
162 | | - origin = CGPoint(x: proposedContentOffset.x, y: collectionView.contentOffset.y) |
163 | | - case .vertical: |
164 | | - origin = CGPoint(x: collectionView.contentOffset.x, y: proposedContentOffset.y) |
| 153 | + |
| 154 | + func determineProposedRect(collectionView: UICollectionView, proposedContentOffset: CGPoint) -> CGRect { |
| 155 | + let size = collectionView.bounds.size |
| 156 | + let origin: CGPoint |
| 157 | + switch scrollDirection { |
| 158 | + case .horizontal: |
| 159 | + origin = CGPoint(x: proposedContentOffset.x, y: collectionView.contentOffset.y) |
| 160 | + case .vertical: |
| 161 | + origin = CGPoint(x: collectionView.contentOffset.x, y: proposedContentOffset.y) |
165 | 162 | default: |
166 | 163 | origin = .zero |
167 | | - } |
168 | | - return CGRect(origin: origin, size: size) |
169 | | - } |
170 | | - |
171 | | - func attributesForRect( |
172 | | - collectionView: UICollectionView, |
173 | | - layoutAttributes: [UICollectionViewLayoutAttributes], |
174 | | - proposedContentOffset: CGPoint |
175 | | - ) -> UICollectionViewLayoutAttributes? { |
176 | | - |
177 | | - var candidateAttributes: UICollectionViewLayoutAttributes? |
178 | | - let proposedCenterOffset: CGFloat |
179 | | - |
180 | | - switch scrollDirection { |
181 | | - case .horizontal: |
182 | | - proposedCenterOffset = proposedContentOffset.x + collectionView.bounds.size.width / 2 |
183 | | - case .vertical: |
184 | | - proposedCenterOffset = proposedContentOffset.y + collectionView.bounds.size.height / 2 |
| 164 | + } |
| 165 | + return CGRect(origin: origin, size: size) |
| 166 | + } |
| 167 | + |
| 168 | + func attributesForRect( |
| 169 | + collectionView: UICollectionView, |
| 170 | + layoutAttributes: [UICollectionViewLayoutAttributes], |
| 171 | + proposedContentOffset: CGPoint |
| 172 | + ) -> UICollectionViewLayoutAttributes? { |
| 173 | + |
| 174 | + var candidateAttributes: UICollectionViewLayoutAttributes? |
| 175 | + let proposedCenterOffset: CGFloat |
| 176 | + |
| 177 | + switch scrollDirection { |
| 178 | + case .horizontal: |
| 179 | + proposedCenterOffset = proposedContentOffset.x + collectionView.bounds.size.width / 2 |
| 180 | + case .vertical: |
| 181 | + proposedCenterOffset = proposedContentOffset.y + collectionView.bounds.size.height / 2 |
185 | 182 | default: |
186 | 183 | proposedCenterOffset = .zero |
187 | | - } |
188 | | - |
189 | | - for attributes in layoutAttributes { |
190 | | - guard attributes.representedElementCategory == .cell else { continue } |
191 | | - guard candidateAttributes != nil else { |
192 | | - candidateAttributes = attributes |
193 | | - continue |
194 | | - } |
195 | | - |
196 | | - switch scrollDirection { |
197 | | - case .horizontal where abs(attributes.center.x - proposedCenterOffset) < abs(candidateAttributes!.center.x - proposedCenterOffset): |
198 | | - candidateAttributes = attributes |
199 | | - case .vertical where abs(attributes.center.y - proposedCenterOffset) < abs(candidateAttributes!.center.y - proposedCenterOffset): |
200 | | - candidateAttributes = attributes |
201 | | - default: |
202 | | - continue |
203 | | - } |
204 | | - } |
205 | | - return candidateAttributes |
206 | | - } |
| 184 | + } |
| 185 | + |
| 186 | + for attributes in layoutAttributes { |
| 187 | + guard attributes.representedElementCategory == .cell else { continue } |
| 188 | + guard candidateAttributes != nil else { |
| 189 | + candidateAttributes = attributes |
| 190 | + continue |
| 191 | + } |
| 192 | + |
| 193 | + switch scrollDirection { |
| 194 | + case .horizontal where abs(attributes.center.x - proposedCenterOffset) < abs(candidateAttributes!.center.x - proposedCenterOffset): |
| 195 | + candidateAttributes = attributes |
| 196 | + case .vertical where abs(attributes.center.y - proposedCenterOffset) < abs(candidateAttributes!.center.y - proposedCenterOffset): |
| 197 | + candidateAttributes = attributes |
| 198 | + default: |
| 199 | + continue |
| 200 | + } |
| 201 | + } |
| 202 | + return candidateAttributes |
| 203 | + } |
207 | 204 | } |
0 commit comments