Slather logo

Coverage for "ChatLayout.swift" : 0.00%

(0 of 725 relevant lines covered)

ChatLayout/Classes/Core/ChatLayout.swift

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