Slather logo

Coverage for "CollectionViewChatLayout.swift" : 0.00%

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