1 |
//
|
|
2 |
// ChatLayout
|
|
3 |
// CollectionViewChatLayout.swift
|
|
4 |
// https://github.com/ekazaev/ChatLayout
|
|
5 |
//
|
|
6 |
// Created by Eugene Kazaev in 2020-2022.
|
|
7 |
// Distributed under the MIT license.
|
|
8 |
//
|
|
9 |
|
|
10 |
import Foundation
|
|
11 |
import UIKit
|
|
12 |
|
|
13 |
/// A collection view layout that can display items in a grid similar to `UITableView` but aligning them
|
|
14 |
/// to the leading or trailing edge of the `UICollectionView`. Helps to maintain chat like behavior by keeping
|
|
15 |
/// content offset from the bottom constant. Can deal with autosizing cells and supplementary views.
|
|
16 |
/// ### Custom Properties:
|
|
17 |
/// `CollectionViewChatLayout.delegate`
|
|
18 |
///
|
|
19 |
/// `CollectionViewChatLayout.settings`
|
|
20 |
///
|
|
21 |
/// `CollectionViewChatLayout.keepContentOffsetAtBottomOnBatchUpdates`
|
|
22 |
///
|
|
23 |
/// `CollectionViewChatLayout.visibleBounds`
|
|
24 |
///
|
|
25 |
/// `CollectionViewChatLayout.layoutFrame`
|
|
26 |
///
|
|
27 |
/// ### Custom Methods:
|
|
28 |
/// `CollectionViewChatLayout.getContentOffsetSnapshot(...)`
|
|
29 |
///
|
|
30 |
/// `CollectionViewChatLayout.restoreContentOffset(...)`
|
|
31 |
public final class CollectionViewChatLayout: UICollectionViewLayout {
|
|
32 |
|
|
33 |
// MARK: Custom Properties
|
|
34 |
|
|
35 |
/// `CollectionViewChatLayout` delegate.
|
|
36 |
public weak var delegate: ChatLayoutDelegate?
|
|
37 |
|
|
38 |
/// Additional settings for `CollectionViewChatLayout`.
|
|
39 |
public var settings = ChatLayoutSettings() {
|
! |
40 |
didSet {
|
! |
41 |
guard collectionView != nil,
|
! |
42 |
settings != oldValue else {
|
! |
43 |
return
|
! |
44 |
}
|
! |
45 |
invalidateLayout()
|
! |
46 |
}
|
! |
47 |
}
|
|
48 |
|
|
49 |
/// Default `UIScrollView` behaviour is to keep content offset constant from the top edge. If this flag is set to `true`
|
|
50 |
/// `CollectionViewChatLayout` should try to compensate batch update changes to keep the current content at the bottom of the visible
|
|
51 |
/// part of `UICollectionView`.
|
|
52 |
///
|
|
53 |
/// **NB:**
|
|
54 |
/// Keep in mind that if during the batch content inset changes also (e.g. keyboard frame changes), `CollectionViewChatLayout` will usually get that information after
|
|
55 |
/// the animation starts and wont be able to compensate that change too. It should be done manually.
|
|
56 |
public var keepContentOffsetAtBottomOnBatchUpdates: Bool = false
|
|
57 |
|
|
58 |
/// Represent the currently visible rectangle.
|
|
59 |
public var visibleBounds: CGRect {
|
! |
60 |
guard let collectionView = collectionView else {
|
! |
61 |
return .zero
|
! |
62 |
}
|
! |
63 |
return CGRect(x: adjustedContentInset.left,
|
! |
64 |
y: collectionView.contentOffset.y + adjustedContentInset.top,
|
! |
65 |
width: collectionView.bounds.width - adjustedContentInset.left - adjustedContentInset.right,
|
! |
66 |
height: collectionView.bounds.height - adjustedContentInset.top - adjustedContentInset.bottom)
|
! |
67 |
}
|
! |
68 |
|
|
69 |
/// Represent the rectangle where all the items are aligned.
|
|
70 |
public var layoutFrame: CGRect {
|
! |
71 |
guard let collectionView = collectionView else {
|
! |
72 |
return .zero
|
! |
73 |
}
|
! |
74 |
let additionalInsets = settings.additionalInsets
|
! |
75 |
return CGRect(x: adjustedContentInset.left + additionalInsets.left,
|
! |
76 |
y: adjustedContentInset.top + additionalInsets.top,
|
! |
77 |
width: collectionView.bounds.width - additionalInsets.left - additionalInsets.right - adjustedContentInset.left - adjustedContentInset.right,
|
! |
78 |
height: controller.contentHeight(at: state) - additionalInsets.top - additionalInsets.bottom - adjustedContentInset.top - adjustedContentInset.bottom)
|
! |
79 |
}
|
! |
80 |
|
|
81 |
// MARK: Inherited Properties
|
|
82 |
|
|
83 |
/// The direction of the language you used when designing `CollectionViewChatLayout` layout.
|
|
84 |
public override var developmentLayoutDirection: UIUserInterfaceLayoutDirection {
|
! |
85 |
return .leftToRight
|
! |
86 |
}
|
! |
87 |
|
|
88 |
/// A Boolean value that indicates whether the horizontal coordinate system is automatically flipped at appropriate times.
|
|
89 |
public override var flipsHorizontallyInOppositeLayoutDirection: Bool {
|
! |
90 |
return _flipsHorizontallyInOppositeLayoutDirection
|
! |
91 |
}
|
! |
92 |
|
|
93 |
/// Custom layoutAttributesClass is `ChatLayoutAttributes`.
|
|
94 |
public override class var layoutAttributesClass: AnyClass {
|
! |
95 |
return ChatLayoutAttributes.self
|
! |
96 |
}
|
! |
97 |
|
|
98 |
/// Custom invalidationContextClass is `ChatLayoutInvalidationContext`.
|
|
99 |
public override class var invalidationContextClass: AnyClass {
|
! |
100 |
return ChatLayoutInvalidationContext.self
|
! |
101 |
}
|
! |
102 |
|
|
103 |
/// The width and height of the collection view’s contents.
|
|
104 |
public override var collectionViewContentSize: CGSize {
|
! |
105 |
let contentSize: CGSize
|
! |
106 |
if state == .beforeUpdate {
|
! |
107 |
contentSize = controller.contentSize(for: .beforeUpdate)
|
! |
108 |
} else {
|
! |
109 |
var size = controller.contentSize(for: .beforeUpdate)
|
! |
110 |
size.height += controller.totalProposedCompensatingOffset
|
! |
111 |
contentSize = size
|
! |
112 |
}
|
! |
113 |
return contentSize
|
! |
114 |
}
|
! |
115 |
|
|
116 |
/// There is an issue in IOS 15.1 that proposed content offset is being ignored by the UICollectionView when user is scrolling.
|
|
117 |
/// This flag enables a hack to compensate this offset later. You can disable it if necessary.
|
|
118 |
/// Bug reported: https://feedbackassistant.apple.com/feedback/9727104
|
|
119 |
///
|
|
120 |
/// PS: This issue was fixed in 15.2
|
|
121 |
public var enableIOS15_1Fix: Bool = true
|
|
122 |
|
|
123 |
// MARK: Internal Properties
|
|
124 |
|
|
125 |
var adjustedContentInset: UIEdgeInsets {
|
! |
126 |
guard let collectionView = collectionView else {
|
! |
127 |
return .zero
|
! |
128 |
}
|
! |
129 |
return collectionView.adjustedContentInset
|
! |
130 |
}
|
! |
131 |
|
|
132 |
var viewSize: CGSize {
|
! |
133 |
guard let collectionView = collectionView else {
|
! |
134 |
return .zero
|
! |
135 |
}
|
! |
136 |
return collectionView.frame.size
|
! |
137 |
}
|
! |
138 |
|
|
139 |
// MARK: Private Properties
|
|
140 |
|
|
141 |
private struct PrepareActions: OptionSet {
|
|
142 |
|
|
143 |
let rawValue: UInt
|
|
144 |
|
|
145 |
static let recreateSectionModels = PrepareActions(rawValue: 1 << 0)
|
|
146 |
static let updateLayoutMetrics = PrepareActions(rawValue: 1 << 1)
|
|
147 |
static let cachePreviousWidth = PrepareActions(rawValue: 1 << 2)
|
|
148 |
static let cachePreviousContentInsets = PrepareActions(rawValue: 1 << 3)
|
|
149 |
static let switchStates = PrepareActions(rawValue: 1 << 4)
|
|
150 |
|
|
151 |
}
|
|
152 |
|
|
153 |
private struct InvalidationActions: OptionSet {
|
|
154 |
|
|
155 |
let rawValue: UInt
|
|
156 |
|
|
157 |
static let shouldInvalidateOnBoundsChange = InvalidationActions(rawValue: 1 << 0)
|
|
158 |
|
|
159 |
}
|
|
160 |
|
|
161 |
private lazy var controller = StateController(layoutRepresentation: self)
|
|
162 |
|
|
163 |
private var state: ModelState = .beforeUpdate
|
! |
164 |
|
|
165 |
private var prepareActions: PrepareActions = []
|
! |
166 |
|
|
167 |
private var invalidationActions: InvalidationActions = []
|
! |
168 |
|
|
169 |
private var cachedCollectionViewSize: CGSize?
|
|
170 |
|
|
171 |
private var cachedCollectionViewInset: UIEdgeInsets?
|
|
172 |
|
|
173 |
// These properties are used to keep the layout attributes copies used for insert/delete
|
|
174 |
// animations up-to-date as items are self-sized. If we don't keep these copies up-to-date, then
|
|
175 |
// animations will start from the estimated height.
|
|
176 |
private var attributesForPendingAnimations = [ItemKind: [ItemPath: ChatLayoutAttributes]]()
|
! |
177 |
|
|
178 |
private var invalidatedAttributes = [ItemKind: Set<ItemPath>]()
|
! |
179 |
|
|
180 |
private var dontReturnAttributes: Bool = true
|
|
181 |
|
|
182 |
private var currentPositionSnapshot: ChatLayoutPositionSnapshot?
|
|
183 |
|
|
184 |
private let _flipsHorizontallyInOppositeLayoutDirection: Bool
|
|
185 |
|
|
186 |
// MARK: IOS 15.1 fix flags
|
|
187 |
|
|
188 |
private var needsIOS15_1IssueFix: Bool {
|
! |
189 |
guard enableIOS15_1Fix else { return false }
|
! |
190 |
guard #unavailable(iOS 15.2) else { return false }
|
! |
191 |
guard #available(iOS 15.1, *) else { return false }
|
! |
192 |
return isUserInitiatedScrolling && !controller.isAnimatedBoundsChange
|
! |
193 |
}
|
! |
194 |
|
|
195 |
// MARK: Constructors
|
|
196 |
|
|
197 |
/// Default constructor.
|
|
198 |
/// - Parameters:
|
|
199 |
/// - flipsHorizontallyInOppositeLayoutDirection: Indicates whether the horizontal coordinate
|
|
200 |
/// system is automatically flipped at appropriate times. In practice, this is used to support
|
|
201 |
/// right-to-left layout.
|
|
202 |
public init(flipsHorizontallyInOppositeLayoutDirection: Bool = true) {
|
! |
203 |
self._flipsHorizontallyInOppositeLayoutDirection = flipsHorizontallyInOppositeLayoutDirection
|
! |
204 |
super.init()
|
! |
205 |
resetAttributesForPendingAnimations()
|
! |
206 |
resetInvalidatedAttributes()
|
! |
207 |
}
|
! |
208 |
|
|
209 |
/// Returns an object initialized from data in a given unarchiver.
|
|
210 |
public required init?(coder aDecoder: NSCoder) {
|
! |
211 |
self._flipsHorizontallyInOppositeLayoutDirection = true
|
! |
212 |
super.init(coder: aDecoder)
|
! |
213 |
resetAttributesForPendingAnimations()
|
! |
214 |
resetInvalidatedAttributes()
|
! |
215 |
}
|
! |
216 |
|
|
217 |
// MARK: Custom Methods
|
|
218 |
|
|
219 |
/// Get current offset of the item closest to the provided edge.
|
|
220 |
/// - Parameter edge: The edge of the `UICollectionView`
|
|
221 |
/// - Returns: `ChatLayoutPositionSnapshot`
|
|
222 |
public func getContentOffsetSnapshot(from edge: ChatLayoutPositionSnapshot.Edge) -> ChatLayoutPositionSnapshot? {
|
! |
223 |
guard let collectionView = collectionView else {
|
! |
224 |
return nil
|
! |
225 |
}
|
! |
226 |
let insets = UIEdgeInsets(top: -collectionView.frame.height,
|
! |
227 |
left: 0,
|
! |
228 |
bottom: -collectionView.frame.height,
|
! |
229 |
right: 0)
|
! |
230 |
let layoutAttributes = controller.layoutAttributesForElements(in: visibleBounds.inset(by: insets),
|
! |
231 |
state: state,
|
! |
232 |
ignoreCache: true)
|
! |
233 |
.sorted(by: { $0.frame.maxY < $1.frame.maxY })
|
! |
234 |
|
! |
235 |
switch edge {
|
! |
236 |
case .top:
|
! |
237 |
guard let firstVisibleItemAttributes = layoutAttributes.first(where: { $0.frame.minY >= visibleBounds.higherPoint.y }) else {
|
! |
238 |
return nil
|
! |
239 |
}
|
! |
240 |
let visibleBoundsTopOffset = firstVisibleItemAttributes.frame.minY - visibleBounds.higherPoint.y - settings.additionalInsets.top
|
! |
241 |
return ChatLayoutPositionSnapshot(indexPath: firstVisibleItemAttributes.indexPath, kind: firstVisibleItemAttributes.kind, edge: .top, offset: visibleBoundsTopOffset)
|
! |
242 |
case .bottom:
|
! |
243 |
guard let lastVisibleItemAttributes = layoutAttributes.last(where: { $0.frame.minY <= visibleBounds.lowerPoint.y }) else {
|
! |
244 |
return nil
|
! |
245 |
}
|
! |
246 |
let visibleBoundsBottomOffset = visibleBounds.lowerPoint.y - lastVisibleItemAttributes.frame.maxY - settings.additionalInsets.bottom
|
! |
247 |
return ChatLayoutPositionSnapshot(indexPath: lastVisibleItemAttributes.indexPath, kind: lastVisibleItemAttributes.kind, edge: .bottom, offset: visibleBoundsBottomOffset)
|
! |
248 |
}
|
! |
249 |
}
|
! |
250 |
|
|
251 |
/// Invalidates layout of the `UICollectionView` and trying to keep the offset of the item provided in `ChatLayoutPositionSnapshot`
|
|
252 |
/// - Parameter snapshot: `ChatLayoutPositionSnapshot`
|
|
253 |
public func restoreContentOffset(with snapshot: ChatLayoutPositionSnapshot) {
|
! |
254 |
guard let collectionView = collectionView else {
|
! |
255 |
return
|
! |
256 |
}
|
! |
257 |
collectionView.setNeedsLayout()
|
! |
258 |
collectionView.layoutIfNeeded()
|
! |
259 |
currentPositionSnapshot = snapshot
|
! |
260 |
let context = ChatLayoutInvalidationContext()
|
! |
261 |
context.invalidateLayoutMetrics = false
|
! |
262 |
invalidateLayout(with: context)
|
! |
263 |
collectionView.setNeedsLayout()
|
! |
264 |
collectionView.layoutIfNeeded()
|
! |
265 |
currentPositionSnapshot = nil
|
! |
266 |
}
|
! |
267 |
|
|
268 |
// MARK: Providing Layout Attributes
|
|
269 |
|
|
270 |
/// Tells the layout object to update the current layout.
|
|
271 |
public override func prepare() {
|
! |
272 |
super.prepare()
|
! |
273 |
|
! |
274 |
guard let collectionView = collectionView,
|
! |
275 |
!prepareActions.isEmpty else {
|
! |
276 |
return
|
! |
277 |
}
|
! |
278 |
|
! |
279 |
if collectionView.isPrefetchingEnabled {
|
! |
280 |
preconditionFailure("UICollectionView with prefetching enabled is not supported due to https://openradar.appspot.com/40926834 bug.")
|
! |
281 |
}
|
! |
282 |
|
! |
283 |
if prepareActions.contains(.switchStates) {
|
! |
284 |
controller.commitUpdates()
|
! |
285 |
state = .beforeUpdate
|
! |
286 |
resetAttributesForPendingAnimations()
|
! |
287 |
resetInvalidatedAttributes()
|
! |
288 |
}
|
! |
289 |
|
! |
290 |
if prepareActions.contains(.recreateSectionModels) {
|
! |
291 |
var sections: [SectionModel] = []
|
! |
292 |
for sectionIndex in 0..<collectionView.numberOfSections {
|
! |
293 |
// Header
|
! |
294 |
let header: ItemModel?
|
! |
295 |
if delegate?.shouldPresentHeader(self, at: sectionIndex) == true {
|
! |
296 |
let headerPath = ItemPath(item: 0, section: sectionIndex)
|
! |
297 |
header = ItemModel(with: configuration(for: .header, at: headerPath))
|
! |
298 |
} else {
|
! |
299 |
header = nil
|
! |
300 |
}
|
! |
301 |
|
! |
302 |
// Items
|
! |
303 |
var items: [ItemModel] = []
|
! |
304 |
for itemIndex in 0..<collectionView.numberOfItems(inSection: sectionIndex) {
|
! |
305 |
let itemPath = ItemPath(item: itemIndex, section: sectionIndex)
|
! |
306 |
items.append(ItemModel(with: configuration(for: .cell, at: itemPath)))
|
! |
307 |
}
|
! |
308 |
|
! |
309 |
// Footer
|
! |
310 |
let footer: ItemModel?
|
! |
311 |
if delegate?.shouldPresentFooter(self, at: sectionIndex) == true {
|
! |
312 |
let footerPath = ItemPath(item: 0, section: sectionIndex)
|
! |
313 |
footer = ItemModel(with: configuration(for: .footer, at: footerPath))
|
! |
314 |
} else {
|
! |
315 |
footer = nil
|
! |
316 |
}
|
! |
317 |
var section = SectionModel(header: header, footer: footer, items: items, collectionLayout: self)
|
! |
318 |
section.assembleLayout()
|
! |
319 |
sections.append(section)
|
! |
320 |
}
|
! |
321 |
controller.set(sections, at: .beforeUpdate)
|
! |
322 |
}
|
! |
323 |
|
! |
324 |
if prepareActions.contains(.updateLayoutMetrics),
|
! |
325 |
!prepareActions.contains(.recreateSectionModels) {
|
! |
326 |
|
! |
327 |
var sections: [SectionModel] = []
|
! |
328 |
sections.reserveCapacity(controller.numberOfSections(at: state))
|
! |
329 |
for sectionIndex in 0..<controller.numberOfSections(at: state) {
|
! |
330 |
var section = controller.section(at: sectionIndex, at: state)
|
! |
331 |
|
! |
332 |
// Header
|
! |
333 |
if delegate?.shouldPresentHeader(self, at: sectionIndex) == true {
|
! |
334 |
var header = section.header
|
! |
335 |
header?.resetSize()
|
! |
336 |
section.set(header: header)
|
! |
337 |
} else {
|
! |
338 |
section.set(header: nil)
|
! |
339 |
}
|
! |
340 |
|
! |
341 |
// Items
|
! |
342 |
var items: [ItemModel] = []
|
! |
343 |
items.reserveCapacity(section.items.count)
|
! |
344 |
for rowIndex in 0..<section.items.count {
|
! |
345 |
var item = section.items[rowIndex]
|
! |
346 |
item.resetSize()
|
! |
347 |
items.append(item)
|
! |
348 |
}
|
! |
349 |
section.set(items: items)
|
! |
350 |
|
! |
351 |
// Footer
|
! |
352 |
if delegate?.shouldPresentFooter(self, at: sectionIndex) == true {
|
! |
353 |
var footer = section.footer
|
! |
354 |
footer?.resetSize()
|
! |
355 |
section.set(footer: footer)
|
! |
356 |
} else {
|
! |
357 |
section.set(footer: nil)
|
! |
358 |
}
|
! |
359 |
|
! |
360 |
section.assembleLayout()
|
! |
361 |
sections.append(section)
|
! |
362 |
}
|
! |
363 |
controller.set(sections, at: state)
|
! |
364 |
}
|
! |
365 |
|
! |
366 |
if prepareActions.contains(.cachePreviousContentInsets) {
|
! |
367 |
cachedCollectionViewInset = adjustedContentInset
|
! |
368 |
}
|
! |
369 |
|
! |
370 |
if prepareActions.contains(.cachePreviousWidth) {
|
! |
371 |
cachedCollectionViewSize = collectionView.bounds.size
|
! |
372 |
}
|
! |
373 |
|
! |
374 |
prepareActions = []
|
! |
375 |
}
|
! |
376 |
|
|
377 |
/// Retrieves the layout attributes for all of the cells and views in the specified rectangle.
|
|
378 |
public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
|
! |
379 |
// This early return prevents an issue that causes overlapping / misplaced elements after an
|
! |
380 |
// off-screen batch update occurs. The root cause of this issue is that `UICollectionView`
|
! |
381 |
// expects `layoutAttributesForElementsInRect:` to return post-batch-update layout attributes
|
! |
382 |
// immediately after an update is sent to the collection view via the insert/delete/reload/move
|
! |
383 |
// functions. Unfortunately, this is impossible - when batch updates occur, `invalidateLayout:`
|
! |
384 |
// is invoked immediately with a context that has `invalidateDataSourceCounts` set to `true`.
|
! |
385 |
// At this time, `CollectionViewChatLayout` has no way of knowing the details of this data source count
|
! |
386 |
// change (where the insert/delete/move took place). `CollectionViewChatLayout` only gets this additional
|
! |
387 |
// information once `prepareForCollectionViewUpdates:` is invoked. At that time, we're able to
|
! |
388 |
// update our layout's source of truth, the `StateController`, which allows us to resolve the
|
! |
389 |
// post-batch-update layout and return post-batch-update layout attributes from this function.
|
! |
390 |
// Between the time that `invalidateLayout:` is invoked with `invalidateDataSourceCounts` set to
|
! |
391 |
// `true`, and when `prepareForCollectionViewUpdates:` is invoked with details of the updates,
|
! |
392 |
// `layoutAttributesForElementsInRect:` is invoked with the expectation that we already have a
|
! |
393 |
// fully resolved layout. If we return incorrect layout attributes at that time, then we'll have
|
! |
394 |
// overlapping elements / visual defects. To prevent this, we can return `nil` in this
|
! |
395 |
// situation, which works around the bug.
|
! |
396 |
// `UICollectionViewCompositionalLayout`, in classic UIKit fashion, avoids this bug / feature by
|
! |
397 |
// implementing the private function
|
! |
398 |
// `_prepareForCollectionViewUpdates:withDataSourceTranslator:`, which provides the layout with
|
! |
399 |
// details about the updates to the collection view before `layoutAttributesForElementsInRect:`
|
! |
400 |
// is invoked, enabling them to resolve their layout in time.
|
! |
401 |
guard !dontReturnAttributes else {
|
! |
402 |
return nil
|
! |
403 |
}
|
! |
404 |
|
! |
405 |
let visibleAttributes = controller.layoutAttributesForElements(in: rect, state: state)
|
! |
406 |
return visibleAttributes
|
! |
407 |
}
|
! |
408 |
|
|
409 |
/// Retrieves layout information for an item at the specified index path with a corresponding cell.
|
|
410 |
public override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
|
! |
411 |
guard !dontReturnAttributes else {
|
! |
412 |
return nil
|
! |
413 |
}
|
! |
414 |
let attributes = controller.itemAttributes(for: indexPath.itemPath, kind: .cell, at: state)
|
! |
415 |
|
! |
416 |
return attributes
|
! |
417 |
}
|
! |
418 |
|
|
419 |
/// Retrieves the layout attributes for the specified supplementary view.
|
|
420 |
public override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
|
! |
421 |
guard !dontReturnAttributes else {
|
! |
422 |
return nil
|
! |
423 |
}
|
! |
424 |
|
! |
425 |
let kind = ItemKind(elementKind)
|
! |
426 |
let attributes = controller.itemAttributes(for: indexPath.itemPath, kind: kind, at: state)
|
! |
427 |
|
! |
428 |
return attributes
|
! |
429 |
}
|
! |
430 |
|
|
431 |
// MARK: Coordinating Animated Changes
|
|
432 |
|
|
433 |
/// Prepares the layout object for animated changes to the view’s bounds or the insertion or deletion of items.
|
|
434 |
public override func prepare(forAnimatedBoundsChange oldBounds: CGRect) {
|
! |
435 |
controller.isAnimatedBoundsChange = true
|
! |
436 |
controller.process(changeItems: [])
|
! |
437 |
state = .afterUpdate
|
! |
438 |
prepareActions.remove(.switchStates)
|
! |
439 |
guard let collectionView = collectionView,
|
! |
440 |
oldBounds.width != collectionView.bounds.width,
|
! |
441 |
keepContentOffsetAtBottomOnBatchUpdates,
|
! |
442 |
controller.isLayoutBiggerThanVisibleBounds(at: state) else {
|
! |
443 |
return
|
! |
444 |
}
|
! |
445 |
let newBounds = collectionView.bounds
|
! |
446 |
let heightDifference = oldBounds.height - newBounds.height
|
! |
447 |
controller.proposedCompensatingOffset += heightDifference + (oldBounds.origin.y - newBounds.origin.y)
|
! |
448 |
}
|
! |
449 |
|
|
450 |
/// Cleans up after any animated changes to the view’s bounds or after the insertion or deletion of items.
|
|
451 |
public override func finalizeAnimatedBoundsChange() {
|
! |
452 |
if controller.isAnimatedBoundsChange {
|
! |
453 |
state = .beforeUpdate
|
! |
454 |
resetInvalidatedAttributes()
|
! |
455 |
resetAttributesForPendingAnimations()
|
! |
456 |
controller.commitUpdates()
|
! |
457 |
controller.isAnimatedBoundsChange = false
|
! |
458 |
controller.proposedCompensatingOffset = 0
|
! |
459 |
controller.batchUpdateCompensatingOffset = 0
|
! |
460 |
}
|
! |
461 |
}
|
! |
462 |
|
|
463 |
// MARK: Context Invalidation
|
|
464 |
|
|
465 |
/// Asks the layout object if changes to a self-sizing cell require a layout update.
|
|
466 |
public override func shouldInvalidateLayout(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> Bool {
|
! |
467 |
let preferredAttributesItemPath = preferredAttributes.indexPath.itemPath
|
! |
468 |
guard let preferredMessageAttributes = preferredAttributes as? ChatLayoutAttributes,
|
! |
469 |
let item = controller.item(for: preferredAttributesItemPath, kind: preferredMessageAttributes.kind, at: state) else {
|
! |
470 |
return true
|
! |
471 |
}
|
! |
472 |
|
! |
473 |
let shouldInvalidateLayout = item.calculatedSize == nil || item.alignment != preferredMessageAttributes.alignment
|
! |
474 |
|
! |
475 |
return shouldInvalidateLayout
|
! |
476 |
}
|
! |
477 |
|
|
478 |
/// Retrieves a context object that identifies the portions of the layout that should change in response to dynamic cell changes.
|
|
479 |
public override func invalidationContext(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutInvalidationContext {
|
! |
480 |
guard let preferredMessageAttributes = preferredAttributes as? ChatLayoutAttributes else {
|
! |
481 |
return super.invalidationContext(forPreferredLayoutAttributes: preferredAttributes, withOriginalAttributes: originalAttributes)
|
! |
482 |
}
|
! |
483 |
|
! |
484 |
let preferredAttributesItemPath = preferredMessageAttributes.indexPath.itemPath
|
! |
485 |
|
! |
486 |
if state == .afterUpdate {
|
! |
487 |
invalidatedAttributes[preferredMessageAttributes.kind]?.insert(preferredAttributesItemPath)
|
! |
488 |
}
|
! |
489 |
|
! |
490 |
let layoutAttributesForPendingAnimation = attributesForPendingAnimations[preferredMessageAttributes.kind]?[preferredAttributesItemPath]
|
! |
491 |
|
! |
492 |
let newItemSize = itemSize(with: preferredMessageAttributes)
|
! |
493 |
|
! |
494 |
controller.update(preferredSize: newItemSize,
|
! |
495 |
alignment: preferredMessageAttributes.alignment,
|
! |
496 |
for: preferredAttributesItemPath,
|
! |
497 |
kind: preferredMessageAttributes.kind,
|
! |
498 |
at: state)
|
! |
499 |
|
! |
500 |
let context = super.invalidationContext(forPreferredLayoutAttributes: preferredMessageAttributes, withOriginalAttributes: originalAttributes) as! ChatLayoutInvalidationContext
|
! |
501 |
|
! |
502 |
let heightDifference = newItemSize.height - originalAttributes.size.height
|
! |
503 |
let isAboveBottomEdge = originalAttributes.frame.minY.rounded() <= visibleBounds.maxY.rounded()
|
! |
504 |
|
! |
505 |
if heightDifference != 0,
|
! |
506 |
(keepContentOffsetAtBottomOnBatchUpdates && controller.contentHeight(at: state).rounded() + heightDifference > visibleBounds.height.rounded()) || isUserInitiatedScrolling,
|
! |
507 |
isAboveBottomEdge {
|
! |
508 |
context.contentOffsetAdjustment.y += heightDifference
|
! |
509 |
invalidationActions.formUnion([.shouldInvalidateOnBoundsChange])
|
! |
510 |
}
|
! |
511 |
|
! |
512 |
if let attributes = controller.itemAttributes(for: preferredAttributesItemPath, kind: preferredMessageAttributes.kind, at: state)?.typedCopy() {
|
! |
513 |
controller.totalProposedCompensatingOffset += heightDifference
|
! |
514 |
layoutAttributesForPendingAnimation?.frame = attributes.frame
|
! |
515 |
if keepContentOffsetAtBottomOnBatchUpdates {
|
! |
516 |
controller.offsetByTotalCompensation(attributes: layoutAttributesForPendingAnimation, for: state, backward: true)
|
! |
517 |
}
|
! |
518 |
if state == .afterUpdate,
|
! |
519 |
controller.insertedIndexes.contains(preferredMessageAttributes.indexPath) ||
|
! |
520 |
controller.insertedSectionsIndexes.contains(preferredMessageAttributes.indexPath.section) {
|
! |
521 |
layoutAttributesForPendingAnimation.map { attributes in
|
! |
522 |
guard let delegate = delegate else {
|
! |
523 |
attributes.alpha = 0
|
! |
524 |
return
|
! |
525 |
}
|
! |
526 |
delegate.initialLayoutAttributesForInsertedItem(self, of: .cell, at: attributes.indexPath, modifying: attributes, on: .invalidation)
|
! |
527 |
}
|
! |
528 |
}
|
! |
529 |
} else {
|
! |
530 |
layoutAttributesForPendingAnimation?.frame.size = newItemSize
|
! |
531 |
}
|
! |
532 |
|
! |
533 |
if #available(iOS 13.0, *) {
|
! |
534 |
switch preferredMessageAttributes.kind {
|
! |
535 |
case .cell:
|
! |
536 |
context.invalidateItems(at: [preferredMessageAttributes.indexPath])
|
! |
537 |
case .header, .footer:
|
! |
538 |
context.invalidateSupplementaryElements(ofKind: preferredMessageAttributes.kind.supplementaryElementStringType, at: [preferredMessageAttributes.indexPath])
|
! |
539 |
}
|
! |
540 |
}
|
! |
541 |
|
! |
542 |
context.invalidateLayoutMetrics = false
|
! |
543 |
|
! |
544 |
return context
|
! |
545 |
}
|
! |
546 |
|
|
547 |
/// Asks the layout object if the new bounds require a layout update.
|
|
548 |
public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
|
! |
549 |
let shouldInvalidateLayout = cachedCollectionViewSize != .some(newBounds.size) ||
|
! |
550 |
cachedCollectionViewInset != .some(adjustedContentInset) ||
|
! |
551 |
invalidationActions.contains(.shouldInvalidateOnBoundsChange)
|
! |
552 |
|
! |
553 |
invalidationActions.remove(.shouldInvalidateOnBoundsChange)
|
! |
554 |
return shouldInvalidateLayout
|
! |
555 |
}
|
! |
556 |
|
|
557 |
/// Retrieves a context object that defines the portions of the layout that should change when a bounds change occurs.
|
|
558 |
public override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
|
! |
559 |
let invalidationContext = super.invalidationContext(forBoundsChange: newBounds) as! ChatLayoutInvalidationContext
|
! |
560 |
invalidationContext.invalidateLayoutMetrics = false
|
! |
561 |
return invalidationContext
|
! |
562 |
}
|
! |
563 |
|
|
564 |
/// Invalidates the current layout using the information in the provided context object.
|
|
565 |
public override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
|
! |
566 |
guard let collectionView = collectionView else {
|
! |
567 |
super.invalidateLayout(with: context)
|
! |
568 |
return
|
! |
569 |
}
|
! |
570 |
|
! |
571 |
guard let context = context as? ChatLayoutInvalidationContext else {
|
! |
572 |
assertionFailure("`context` must be an instance of `ChatLayoutInvalidationContext`")
|
! |
573 |
return
|
! |
574 |
}
|
! |
575 |
|
! |
576 |
controller.resetCachedAttributes()
|
! |
577 |
|
! |
578 |
dontReturnAttributes = context.invalidateDataSourceCounts && !context.invalidateEverything
|
! |
579 |
|
! |
580 |
if context.invalidateEverything {
|
! |
581 |
prepareActions.formUnion([.recreateSectionModels])
|
! |
582 |
}
|
! |
583 |
|
! |
584 |
// Checking `cachedCollectionViewWidth != collectionView.bounds.size.width` is necessary
|
! |
585 |
// because the collection view's width can change without a `contentSizeAdjustment` occurring.
|
! |
586 |
if context.contentSizeAdjustment.width != 0 || cachedCollectionViewSize != collectionView.bounds.size {
|
! |
587 |
prepareActions.formUnion([.cachePreviousWidth])
|
! |
588 |
}
|
! |
589 |
|
! |
590 |
if cachedCollectionViewInset != adjustedContentInset {
|
! |
591 |
prepareActions.formUnion([.cachePreviousContentInsets])
|
! |
592 |
}
|
! |
593 |
|
! |
594 |
if context.invalidateLayoutMetrics, !context.invalidateDataSourceCounts {
|
! |
595 |
prepareActions.formUnion([.updateLayoutMetrics])
|
! |
596 |
}
|
! |
597 |
|
! |
598 |
if let currentPositionSnapshot = currentPositionSnapshot {
|
! |
599 |
let contentHeight = controller.contentHeight(at: state)
|
! |
600 |
if let frame = controller.itemFrame(for: currentPositionSnapshot.indexPath.itemPath, kind: currentPositionSnapshot.kind, at: state, isFinal: true),
|
! |
601 |
contentHeight != 0,
|
! |
602 |
contentHeight > visibleBounds.size.height {
|
! |
603 |
switch currentPositionSnapshot.edge {
|
! |
604 |
case .top:
|
! |
605 |
let desiredOffset = frame.minY - currentPositionSnapshot.offset - collectionView.adjustedContentInset.top - settings.additionalInsets.top
|
! |
606 |
context.contentOffsetAdjustment.y = desiredOffset - collectionView.contentOffset.y
|
! |
607 |
case .bottom:
|
! |
608 |
let maxAllowed = max(-collectionView.adjustedContentInset.top, contentHeight - collectionView.frame.height + collectionView.adjustedContentInset.bottom)
|
! |
609 |
let desiredOffset = max(min(maxAllowed, frame.maxY + currentPositionSnapshot.offset - collectionView.bounds.height + collectionView.adjustedContentInset.bottom + settings.additionalInsets.bottom), -collectionView.adjustedContentInset.top)
|
! |
610 |
context.contentOffsetAdjustment.y = desiredOffset - collectionView.contentOffset.y
|
! |
611 |
}
|
! |
612 |
}
|
! |
613 |
}
|
! |
614 |
super.invalidateLayout(with: context)
|
! |
615 |
}
|
! |
616 |
|
|
617 |
/// Invalidates the current layout and triggers a layout update.
|
|
618 |
public override func invalidateLayout() {
|
! |
619 |
super.invalidateLayout()
|
! |
620 |
}
|
! |
621 |
|
|
622 |
/// Retrieves the content offset to use after an animated layout update or change.
|
|
623 |
public override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
|
! |
624 |
if controller.proposedCompensatingOffset != 0,
|
! |
625 |
let collectionView = collectionView {
|
! |
626 |
let minPossibleContentOffset = -collectionView.adjustedContentInset.top
|
! |
627 |
let newProposedContentOffset = CGPoint(x: proposedContentOffset.x, y: max(minPossibleContentOffset, min(proposedContentOffset.y + controller.proposedCompensatingOffset, maxPossibleContentOffset.y)))
|
! |
628 |
invalidationActions.formUnion([.shouldInvalidateOnBoundsChange])
|
! |
629 |
if needsIOS15_1IssueFix {
|
! |
630 |
controller.proposedCompensatingOffset = 0
|
! |
631 |
collectionView.contentOffset = newProposedContentOffset
|
! |
632 |
return newProposedContentOffset
|
! |
633 |
} else {
|
! |
634 |
controller.proposedCompensatingOffset = 0
|
! |
635 |
return newProposedContentOffset
|
! |
636 |
}
|
! |
637 |
}
|
! |
638 |
return super.targetContentOffset(forProposedContentOffset: proposedContentOffset)
|
! |
639 |
}
|
! |
640 |
|
|
641 |
// MARK: Responding to Collection View Updates
|
|
642 |
|
|
643 |
/// Notifies the layout object that the contents of the collection view are about to change.
|
|
644 |
public override func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) {
|
! |
645 |
let changeItems = updateItems.compactMap { ChangeItem(with: $0) }
|
! |
646 |
controller.process(changeItems: changeItems)
|
! |
647 |
state = .afterUpdate
|
! |
648 |
dontReturnAttributes = false
|
! |
649 |
super.prepare(forCollectionViewUpdates: updateItems)
|
! |
650 |
}
|
! |
651 |
|
|
652 |
/// Performs any additional animations or clean up needed during a collection view update.
|
|
653 |
public override func finalizeCollectionViewUpdates() {
|
! |
654 |
controller.proposedCompensatingOffset = 0
|
! |
655 |
|
! |
656 |
if keepContentOffsetAtBottomOnBatchUpdates,
|
! |
657 |
controller.isLayoutBiggerThanVisibleBounds(at: state),
|
! |
658 |
controller.batchUpdateCompensatingOffset != 0,
|
! |
659 |
let collectionView = collectionView {
|
! |
660 |
let compensatingOffset: CGFloat
|
! |
661 |
if controller.contentSize(for: .beforeUpdate).height > visibleBounds.size.height {
|
! |
662 |
compensatingOffset = controller.batchUpdateCompensatingOffset
|
! |
663 |
} else {
|
! |
664 |
compensatingOffset = maxPossibleContentOffset.y - collectionView.contentOffset.y
|
! |
665 |
}
|
! |
666 |
controller.batchUpdateCompensatingOffset = 0
|
! |
667 |
let context = ChatLayoutInvalidationContext()
|
! |
668 |
context.contentOffsetAdjustment.y = compensatingOffset
|
! |
669 |
invalidateLayout(with: context)
|
! |
670 |
} else {
|
! |
671 |
controller.batchUpdateCompensatingOffset = 0
|
! |
672 |
let context = ChatLayoutInvalidationContext()
|
! |
673 |
invalidateLayout(with: context)
|
! |
674 |
}
|
! |
675 |
|
! |
676 |
prepareActions.formUnion(.switchStates)
|
! |
677 |
|
! |
678 |
super.finalizeCollectionViewUpdates()
|
! |
679 |
}
|
! |
680 |
|
|
681 |
// MARK: - Cell Appearance Animation
|
|
682 |
|
|
683 |
/// Retrieves the starting layout information for an item being inserted into the collection view.
|
|
684 |
public override func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
|
! |
685 |
var attributes: ChatLayoutAttributes?
|
! |
686 |
|
! |
687 |
let itemPath = itemIndexPath.itemPath
|
! |
688 |
if state == .afterUpdate {
|
! |
689 |
if controller.insertedIndexes.contains(itemIndexPath) || controller.insertedSectionsIndexes.contains(itemPath.section) {
|
! |
690 |
attributes = controller.itemAttributes(for: itemPath, kind: .cell, at: .afterUpdate)?.typedCopy()
|
! |
691 |
controller.offsetByTotalCompensation(attributes: attributes, for: state, backward: true)
|
! |
692 |
attributes.map { attributes in
|
! |
693 |
guard let delegate = delegate else {
|
! |
694 |
attributes.alpha = 0
|
! |
695 |
return
|
! |
696 |
}
|
! |
697 |
delegate.initialLayoutAttributesForInsertedItem(self, of: .cell, at: itemIndexPath, modifying: attributes, on: .initial)
|
! |
698 |
}
|
! |
699 |
attributesForPendingAnimations[.cell]?[itemPath] = attributes
|
! |
700 |
} else if let itemIdentifier = controller.itemIdentifier(for: itemPath, kind: .cell, at: .afterUpdate),
|
! |
701 |
let initialIndexPath = controller.itemPath(by: itemIdentifier, kind: .cell, at: .beforeUpdate) {
|
! |
702 |
attributes = controller.itemAttributes(for: initialIndexPath, kind: .cell, at: .beforeUpdate)?.typedCopy() ?? ChatLayoutAttributes(forCellWith: itemIndexPath)
|
! |
703 |
attributes?.indexPath = itemIndexPath
|
! |
704 |
if #unavailable(iOS 13.0) {
|
! |
705 |
if controller.reloadedIndexes.contains(itemIndexPath) || controller.reloadedSectionsIndexes.contains(itemPath.section) {
|
! |
706 |
// It is needed to position the new cell in the middle of the old cell on ios 12
|
! |
707 |
attributesForPendingAnimations[.cell]?[itemPath] = attributes
|
! |
708 |
}
|
! |
709 |
}
|
! |
710 |
} else {
|
! |
711 |
attributes = controller.itemAttributes(for: itemPath, kind: .cell, at: .beforeUpdate)
|
! |
712 |
}
|
! |
713 |
} else {
|
! |
714 |
attributes = controller.itemAttributes(for: itemPath, kind: .cell, at: .beforeUpdate)
|
! |
715 |
}
|
! |
716 |
|
! |
717 |
return attributes
|
! |
718 |
}
|
! |
719 |
|
|
720 |
/// Retrieves the final layout information for an item that is about to be removed from the collection view.
|
|
721 |
public override func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
|
! |
722 |
var attributes: ChatLayoutAttributes?
|
! |
723 |
|
! |
724 |
let itemPath = itemIndexPath.itemPath
|
! |
725 |
if state == .afterUpdate {
|
! |
726 |
if controller.deletedIndexes.contains(itemIndexPath) || controller.deletedSectionsIndexes.contains(itemPath.section) {
|
! |
727 |
attributes = controller.itemAttributes(for: itemPath, kind: .cell, at: .beforeUpdate)?.typedCopy() ?? ChatLayoutAttributes(forCellWith: itemIndexPath)
|
! |
728 |
controller.offsetByTotalCompensation(attributes: attributes, for: state, backward: false)
|
! |
729 |
if keepContentOffsetAtBottomOnBatchUpdates,
|
! |
730 |
controller.isLayoutBiggerThanVisibleBounds(at: state),
|
! |
731 |
let attributes = attributes {
|
! |
732 |
attributes.frame = attributes.frame.offsetBy(dx: 0, dy: attributes.frame.height * 0.2)
|
! |
733 |
}
|
! |
734 |
attributes.map { attributes in
|
! |
735 |
guard let delegate = delegate else {
|
! |
736 |
attributes.alpha = 0
|
! |
737 |
return
|
! |
738 |
}
|
! |
739 |
delegate.finalLayoutAttributesForDeletedItem(self, of: .cell, at: itemIndexPath, modifying: attributes)
|
! |
740 |
}
|
! |
741 |
} else if let itemIdentifier = controller.itemIdentifier(for: itemPath, kind: .cell, at: .beforeUpdate),
|
! |
742 |
let finalIndexPath = controller.itemPath(by: itemIdentifier, kind: .cell, at: .afterUpdate) {
|
! |
743 |
if controller.movedIndexes.contains(itemIndexPath) || controller.movedSectionsIndexes.contains(itemPath.section) ||
|
! |
744 |
controller.reloadedIndexes.contains(itemIndexPath) || controller.reloadedSectionsIndexes.contains(itemPath.section) {
|
! |
745 |
attributes = controller.itemAttributes(for: finalIndexPath, kind: .cell, at: .afterUpdate)?.typedCopy()
|
! |
746 |
} else {
|
! |
747 |
attributes = controller.itemAttributes(for: itemPath, kind: .cell, at: .beforeUpdate)?.typedCopy()
|
! |
748 |
}
|
! |
749 |
if invalidatedAttributes[.cell]?.contains(itemPath) ?? false {
|
! |
750 |
attributes = nil
|
! |
751 |
}
|
! |
752 |
|
! |
753 |
attributes?.indexPath = itemIndexPath
|
! |
754 |
attributesForPendingAnimations[.cell]?[itemPath] = attributes
|
! |
755 |
if controller.reloadedIndexes.contains(itemIndexPath) || controller.reloadedSectionsIndexes.contains(itemPath.section) {
|
! |
756 |
attributes?.alpha = 0
|
! |
757 |
attributes?.transform = CGAffineTransform(scaleX: 0, y: 0)
|
! |
758 |
}
|
! |
759 |
} else {
|
! |
760 |
attributes = controller.itemAttributes(for: itemPath, kind: .cell, at: .beforeUpdate)
|
! |
761 |
}
|
! |
762 |
} else {
|
! |
763 |
attributes = controller.itemAttributes(for: itemPath, kind: .cell, at: .beforeUpdate)
|
! |
764 |
}
|
! |
765 |
|
! |
766 |
return attributes
|
! |
767 |
}
|
! |
768 |
|
|
769 |
// MARK: - Supplementary View Appearance Animation
|
|
770 |
|
|
771 |
/// Retrieves the starting layout information for a supplementary view being inserted into the collection view.
|
|
772 |
public override func initialLayoutAttributesForAppearingSupplementaryElement(ofKind elementKind: String, at elementIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
|
! |
773 |
var attributes: ChatLayoutAttributes?
|
! |
774 |
|
! |
775 |
let kind = ItemKind(elementKind)
|
! |
776 |
let elementPath = elementIndexPath.itemPath
|
! |
777 |
if state == .afterUpdate {
|
! |
778 |
if controller.insertedSectionsIndexes.contains(elementPath.section) {
|
! |
779 |
attributes = controller.itemAttributes(for: elementPath, kind: kind, at: .afterUpdate)?.typedCopy()
|
! |
780 |
controller.offsetByTotalCompensation(attributes: attributes, for: state, backward: true)
|
! |
781 |
attributes.map { attributes in
|
! |
782 |
guard let delegate = delegate else {
|
! |
783 |
attributes.alpha = 0
|
! |
784 |
return
|
! |
785 |
}
|
! |
786 |
delegate.initialLayoutAttributesForInsertedItem(self, of: kind, at: elementIndexPath, modifying: attributes, on: .initial)
|
! |
787 |
}
|
! |
788 |
attributesForPendingAnimations[kind]?[elementPath] = attributes
|
! |
789 |
} else if let itemIdentifier = controller.itemIdentifier(for: elementPath, kind: kind, at: .afterUpdate),
|
! |
790 |
let initialIndexPath = controller.itemPath(by: itemIdentifier, kind: kind, at: .beforeUpdate) {
|
! |
791 |
attributes = controller.itemAttributes(for: initialIndexPath, kind: kind, at: .beforeUpdate)?.typedCopy() ?? ChatLayoutAttributes(forSupplementaryViewOfKind: elementKind, with: elementIndexPath)
|
! |
792 |
attributes?.indexPath = elementIndexPath
|
! |
793 |
|
! |
794 |
if #unavailable(iOS 13.0) {
|
! |
795 |
if controller.reloadedSectionsIndexes.contains(elementPath.section) {
|
! |
796 |
// It is needed to position the new cell in the middle of the old cell on ios 12
|
! |
797 |
attributesForPendingAnimations[kind]?[elementPath] = attributes
|
! |
798 |
}
|
! |
799 |
}
|
! |
800 |
} else {
|
! |
801 |
attributes = controller.itemAttributes(for: elementPath, kind: kind, at: .beforeUpdate)
|
! |
802 |
}
|
! |
803 |
} else {
|
! |
804 |
attributes = controller.itemAttributes(for: elementPath, kind: kind, at: .beforeUpdate)
|
! |
805 |
}
|
! |
806 |
|
! |
807 |
return attributes
|
! |
808 |
}
|
! |
809 |
|
|
810 |
/// Retrieves the final layout information for a supplementary view that is about to be removed from the collection view.
|
|
811 |
public override func finalLayoutAttributesForDisappearingSupplementaryElement(ofKind elementKind: String, at elementIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
|
! |
812 |
var attributes: ChatLayoutAttributes?
|
! |
813 |
|
! |
814 |
let kind = ItemKind(elementKind)
|
! |
815 |
let elementPath = elementIndexPath.itemPath
|
! |
816 |
if state == .afterUpdate {
|
! |
817 |
if controller.deletedSectionsIndexes.contains(elementPath.section) {
|
! |
818 |
attributes = controller.itemAttributes(for: elementPath, kind: kind, at: .beforeUpdate)?.typedCopy() ?? ChatLayoutAttributes(forSupplementaryViewOfKind: elementKind, with: elementIndexPath)
|
! |
819 |
controller.offsetByTotalCompensation(attributes: attributes, for: state, backward: false)
|
! |
820 |
if keepContentOffsetAtBottomOnBatchUpdates,
|
! |
821 |
controller.isLayoutBiggerThanVisibleBounds(at: state),
|
! |
822 |
let attributes = attributes {
|
! |
823 |
attributes.frame = attributes.frame.offsetBy(dx: 0, dy: attributes.frame.height * 0.2)
|
! |
824 |
}
|
! |
825 |
attributes.map { attributes in
|
! |
826 |
guard let delegate = delegate else {
|
! |
827 |
attributes.alpha = 0
|
! |
828 |
return
|
! |
829 |
}
|
! |
830 |
delegate.finalLayoutAttributesForDeletedItem(self, of: .cell, at: elementIndexPath, modifying: attributes)
|
! |
831 |
}
|
! |
832 |
} else if let itemIdentifier = controller.itemIdentifier(for: elementPath, kind: kind, at: .beforeUpdate),
|
! |
833 |
let finalIndexPath = controller.itemPath(by: itemIdentifier, kind: kind, at: .afterUpdate) {
|
! |
834 |
if controller.movedSectionsIndexes.contains(elementPath.section) || controller.reloadedSectionsIndexes.contains(elementPath.section) {
|
! |
835 |
attributes = controller.itemAttributes(for: finalIndexPath, kind: kind, at: .afterUpdate)?.typedCopy()
|
! |
836 |
} else {
|
! |
837 |
attributes = controller.itemAttributes(for: elementPath, kind: kind, at: .beforeUpdate)?.typedCopy()
|
! |
838 |
}
|
! |
839 |
if invalidatedAttributes[kind]?.contains(elementPath) ?? false {
|
! |
840 |
attributes = nil
|
! |
841 |
}
|
! |
842 |
|
! |
843 |
attributes?.indexPath = elementIndexPath
|
! |
844 |
attributesForPendingAnimations[kind]?[elementPath] = attributes
|
! |
845 |
if controller.reloadedSectionsIndexes.contains(elementPath.section) {
|
! |
846 |
attributes?.alpha = 0
|
! |
847 |
attributes?.transform = CGAffineTransform(scaleX: 0, y: 0)
|
! |
848 |
}
|
! |
849 |
} else {
|
! |
850 |
attributes = controller.itemAttributes(for: elementPath, kind: kind, at: .beforeUpdate)
|
! |
851 |
}
|
! |
852 |
} else {
|
! |
853 |
attributes = controller.itemAttributes(for: elementPath, kind: kind, at: .beforeUpdate)
|
! |
854 |
}
|
! |
855 |
return attributes
|
! |
856 |
}
|
! |
857 |
|
|
858 |
}
|
|
859 |
|
|
860 |
extension CollectionViewChatLayout {
|
|
861 |
|
|
862 |
func configuration(for element: ItemKind, at itemPath: ItemPath) -> ItemModel.Configuration {
|
! |
863 |
let indexPath = itemPath.indexPath
|
! |
864 |
let itemSize = estimatedSize(for: element, at: indexPath)
|
! |
865 |
return ItemModel.Configuration(alignment: alignment(for: element, at: indexPath), preferredSize: itemSize.estimated, calculatedSize: itemSize.exact)
|
! |
866 |
}
|
! |
867 |
|
|
868 |
private func estimatedSize(for element: ItemKind, at indexPath: IndexPath) -> (estimated: CGSize, exact: CGSize?) {
|
! |
869 |
guard let delegate = delegate else {
|
! |
870 |
return (estimated: estimatedItemSize, exact: nil)
|
! |
871 |
}
|
! |
872 |
|
! |
873 |
let itemSize = delegate.sizeForItem(self, of: element, at: indexPath)
|
! |
874 |
|
! |
875 |
switch itemSize {
|
! |
876 |
case .auto:
|
! |
877 |
return (estimated: estimatedItemSize, exact: nil)
|
! |
878 |
case let .estimated(size):
|
! |
879 |
return (estimated: size, exact: nil)
|
! |
880 |
case let .exact(size):
|
! |
881 |
return (estimated: size, exact: size)
|
! |
882 |
}
|
! |
883 |
}
|
! |
884 |
|
|
885 |
private func itemSize(with preferredAttributes: ChatLayoutAttributes) -> CGSize {
|
! |
886 |
let itemSize: CGSize
|
! |
887 |
if let delegate = delegate,
|
! |
888 |
case let .exact(size) = delegate.sizeForItem(self, of: preferredAttributes.kind, at: preferredAttributes.indexPath) {
|
! |
889 |
itemSize = size
|
! |
890 |
} else {
|
! |
891 |
itemSize = preferredAttributes.size
|
! |
892 |
}
|
! |
893 |
return itemSize
|
! |
894 |
}
|
! |
895 |
|
|
896 |
private func alignment(for element: ItemKind, at indexPath: IndexPath) -> ChatItemAlignment {
|
! |
897 |
guard let delegate = delegate else {
|
! |
898 |
return .fullWidth
|
! |
899 |
}
|
! |
900 |
return delegate.alignmentForItem(self, of: element, at: indexPath)
|
! |
901 |
}
|
! |
902 |
|
|
903 |
private var estimatedItemSize: CGSize {
|
! |
904 |
guard let estimatedItemSize = settings.estimatedItemSize else {
|
! |
905 |
guard collectionView != nil else {
|
! |
906 |
return .zero
|
! |
907 |
}
|
! |
908 |
return CGSize(width: layoutFrame.width, height: 40)
|
! |
909 |
}
|
! |
910 |
|
! |
911 |
return estimatedItemSize
|
! |
912 |
}
|
! |
913 |
|
|
914 |
private func resetAttributesForPendingAnimations() {
|
! |
915 |
ItemKind.allCases.forEach {
|
! |
916 |
attributesForPendingAnimations[$0] = [:]
|
! |
917 |
}
|
! |
918 |
}
|
! |
919 |
|
|
920 |
private func resetInvalidatedAttributes() {
|
! |
921 |
ItemKind.allCases.forEach {
|
! |
922 |
invalidatedAttributes[$0] = []
|
! |
923 |
}
|
! |
924 |
}
|
! |
925 |
|
|
926 |
}
|
|
927 |
|
|
928 |
extension CollectionViewChatLayout: ChatLayoutRepresentation {
|
|
929 |
|
|
930 |
func numberOfItems(in section: Int) -> Int {
|
! |
931 |
guard let collectionView = collectionView else {
|
! |
932 |
return .zero
|
! |
933 |
}
|
! |
934 |
return collectionView.numberOfItems(inSection: section)
|
! |
935 |
}
|
! |
936 |
|
|
937 |
func shouldPresentHeader(at sectionIndex: Int) -> Bool {
|
! |
938 |
return delegate?.shouldPresentHeader(self, at: sectionIndex) ?? false
|
! |
939 |
}
|
! |
940 |
|
|
941 |
func shouldPresentFooter(at sectionIndex: Int) -> Bool {
|
! |
942 |
return delegate?.shouldPresentFooter(self, at: sectionIndex) ?? false
|
! |
943 |
}
|
! |
944 |
|
|
945 |
}
|
|
946 |
|
|
947 |
extension CollectionViewChatLayout {
|
|
948 |
|
|
949 |
private var maxPossibleContentOffset: CGPoint {
|
! |
950 |
guard let collectionView = collectionView else {
|
! |
951 |
return .zero
|
! |
952 |
}
|
! |
953 |
let maxContentOffset = max(0 - collectionView.adjustedContentInset.top, controller.contentHeight(at: state) - collectionView.frame.height + collectionView.adjustedContentInset.bottom)
|
! |
954 |
return CGPoint(x: 0, y: maxContentOffset)
|
! |
955 |
}
|
! |
956 |
|
|
957 |
private var isUserInitiatedScrolling: Bool {
|
! |
958 |
guard let collectionView = collectionView else {
|
! |
959 |
return false
|
! |
960 |
}
|
! |
961 |
return collectionView.isDragging || collectionView.isDecelerating
|
! |
962 |
}
|
! |
963 |
|
|
964 |
}
|
|