Slather logo

Coverage for "CollectionViewChatLayout.swift" : 0.00%

(0 of 754 relevant lines covered)

ChatLayout/Classes/Core/CollectionViewChatLayout.swift

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