Slather logo

Coverage for "StateController.swift" : 79.29%

(494 of 623 relevant lines covered)

ChatLayout/Classes/Core/Model/StateController.swift

1
//
2
// ChatLayout
3
// StateController.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
/// This protocol exists only to serve an ability to unit test `StateController`.
14
protocol ChatLayoutRepresentation: AnyObject {
15
16
    var settings: ChatLayoutSettings { get }
17
18
    var viewSize: CGSize { get }
19
20
    var visibleBounds: CGRect { get }
21
22
    var layoutFrame: CGRect { get }
23
24
    var adjustedContentInset: UIEdgeInsets { get }
25
26
    var keepContentOffsetAtBottomOnBatchUpdates: Bool { get }
27
28
    func numberOfItems(in section: Int) -> Int
29
30
    func configuration(for element: ItemKind, at itemPath: ItemPath) -> ItemModel.Configuration
31
32
    func shouldPresentHeader(at sectionIndex: Int) -> Bool
33
34
    func shouldPresentFooter(at sectionIndex: Int) -> Bool
35
36
}
37
38
final class StateController {
39
40
    private enum CompensatingAction {
41
        case insert
42
        case delete
43
        case frameUpdate(previousFrame: CGRect, newFrame: CGRect)
44
    }
45
46
    // This thing exists here as `UICollectionView` calls `targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint)` only once at the
47
    // beginning of the animated updates. But we must compensate the other changes that happened during the update.
48
    var batchUpdateCompensatingOffset: CGFloat = 0
49
50
    var proposedCompensatingOffset: CGFloat = 0
51
52
    var totalProposedCompensatingOffset: CGFloat = 0
53
54
    var isAnimatedBoundsChange = false
55
56
    private(set) var storage: [ModelState: LayoutModel]
57
58
    private(set) var reloadedIndexes: Set<IndexPath> = []
23x
59
60
    private(set) var insertedIndexes: Set<IndexPath> = []
23x
61
62
    private(set) var movedIndexes: Set<IndexPath> = []
23x
63
64
    private(set) var deletedIndexes: Set<IndexPath> = []
23x
65
66
    private(set) var reloadedSectionsIndexes: Set<Int> = []
23x
67
68
    private(set) var insertedSectionsIndexes: Set<Int> = []
23x
69
70
    private(set) var deletedSectionsIndexes: Set<Int> = []
23x
71
72
    private(set) var movedSectionsIndexes: Set<Int> = []
23x
73
74
    private var cachedAttributesState: (rect: CGRect, attributes: [ChatLayoutAttributes])?
75
76
    private var cachedAttributeObjects = [ModelState: [ItemKind: [ItemPath: ChatLayoutAttributes]]]()
23x
77
78
    private unowned var layoutRepresentation: ChatLayoutRepresentation
79
80
    init(layoutRepresentation: ChatLayoutRepresentation) {
23x
81
        self.layoutRepresentation = layoutRepresentation
23x
82
        self.storage = [.beforeUpdate: LayoutModel(sections: [], collectionLayout: self.layoutRepresentation)]
23x
83
        resetCachedAttributeObjects()
23x
84
    }
23x
85
86
    func set(_ sections: [SectionModel], at state: ModelState) {
23x
87
        var layoutModel = LayoutModel(sections: sections, collectionLayout: layoutRepresentation)
23x
88
        layoutModel.assembleLayout()
23x
89
        storage[state] = layoutModel
23x
90
    }
23x
91
92
    func contentHeight(at state: ModelState) -> CGFloat {
93
        guard let locationHeight = storage[state]?.sections.last?.locationHeight else {
94
            return 0
10000x
95
        }
96
        return locationHeight + layoutRepresentation.settings.additionalInsets.bottom
97
    }
98
99
    func layoutAttributesForElements(in rect: CGRect,
100
                                     state: ModelState,
101
                                     ignoreCache: Bool = false) -> [ChatLayoutAttributes] {
5x
102
        if !ignoreCache,
5x
103
            let cachedAttributesState = cachedAttributesState,
5x
104
            cachedAttributesState.rect.contains(rect) {
5x
105
            return cachedAttributesState.attributes.filter { $0.frame.intersects(rect) }
13x
106
        } else {
4x
107
            let totalRect = rect.inset(by: UIEdgeInsets(top: -rect.height / 2, left: -rect.width / 2, bottom: -rect.height / 2, right: -rect.width / 2))
4x
108
            let attributes = allAttributes(at: state, visibleRect: totalRect)
4x
109
            if !ignoreCache {
4x
110
                cachedAttributesState = (rect: totalRect, attributes: attributes)
4x
111
            }
4x
112
            let visibleAttributes = attributes.filter { $0.frame.intersects(rect) }
52x
113
            return visibleAttributes
4x
114
        }
4x
115
    }
!
116
117
    func resetCachedAttributes() {
2x
118
        cachedAttributesState = nil
2x
119
    }
2x
120
121
    func resetCachedAttributeObjects() {
66x
122
        ModelState.allCases.forEach { state in
132x
123
            resetCachedAttributeObjects(at: state)
132x
124
        }
132x
125
    }
66x
126
127
    private func resetCachedAttributeObjects(at state: ModelState) {
144x
128
        cachedAttributeObjects[state] = [:]
144x
129
        ItemKind.allCases.forEach { kind in
144x
130
            cachedAttributeObjects[state]?[kind] = [:]
144x
131
        }
144x
132
    }
144x
133
134
    func itemAttributes(for itemPath: ItemPath,
135
                        kind: ItemKind,
136
                        predefinedFrame: CGRect? = nil,
137
                        at state: ModelState) -> ChatLayoutAttributes? {
368x
138
        let attributes: ChatLayoutAttributes
368x
139
        let itemIndexPath = itemPath.indexPath
368x
140
        switch kind {
368x
141
        case .header:
368x
142
            guard itemPath.section < layout(at: state).sections.count,
13x
143
                itemPath.item == 0 else {
13x
144
                // This occurs when getting layout attributes for initial / final animations
!
145
                return nil
!
146
            }
13x
147
            guard let headerFrame = predefinedFrame ?? itemFrame(for: itemPath, kind: kind, at: state, isFinal: true),
13x
148
                let item = item(for: itemPath, kind: kind, at: state) else {
13x
149
                return nil
!
150
            }
13x
151
            if let cachedAttributes = cachedAttributeObjects[state]?[.header]?[itemPath] {
13x
152
                attributes = cachedAttributes
3x
153
            } else {
13x
154
                attributes = ChatLayoutAttributes(forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, with: itemIndexPath)
10x
155
                cachedAttributeObjects[state]?[.header]?[itemPath] = attributes
10x
156
            }
13x
157
            #if DEBUG
13x
158
            attributes.id = item.id
13x
159
            #endif
13x
160
            attributes.frame = headerFrame
13x
161
            attributes.indexPath = itemIndexPath
13x
162
            attributes.zIndex = 10
13x
163
            attributes.alignment = item.alignment
13x
164
        case .footer:
368x
165
            guard itemPath.section < layout(at: state).sections.count,
9x
166
                itemPath.item == 0 else {
9x
167
                // This occurs when getting layout attributes for initial / final animations
!
168
                return nil
!
169
            }
9x
170
            guard let footerFrame = predefinedFrame ?? itemFrame(for: itemPath, kind: kind, at: state, isFinal: true),
9x
171
                let item = item(for: itemPath, kind: kind, at: state) else {
9x
172
                return nil
!
173
            }
9x
174
            if let cachedAttributes = cachedAttributeObjects[state]?[.footer]?[itemPath] {
9x
175
                attributes = cachedAttributes
2x
176
            } else {
9x
177
                attributes = ChatLayoutAttributes(forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, with: itemIndexPath)
7x
178
                cachedAttributeObjects[state]?[.footer]?[itemPath] = attributes
7x
179
            }
9x
180
            #if DEBUG
9x
181
            attributes.id = item.id
9x
182
            #endif
9x
183
            attributes.frame = footerFrame
9x
184
            attributes.indexPath = itemIndexPath
9x
185
            attributes.zIndex = 10
9x
186
            attributes.alignment = item.alignment
9x
187
        case .cell:
368x
188
            guard itemPath.section < layout(at: state).sections.count,
346x
189
                itemPath.item < layout(at: state).sections[itemPath.section].items.count else {
346x
190
                // This occurs when getting layout attributes for initial / final animations
!
191
                return nil
!
192
            }
346x
193
            guard let itemFrame = predefinedFrame ?? itemFrame(for: itemPath, kind: .cell, at: state, isFinal: true),
346x
194
                let item = item(for: itemPath, kind: kind, at: state) else {
346x
195
                return nil
!
196
            }
346x
197
            if let cachedAttributes = cachedAttributeObjects[state]?[.cell]?[itemPath] {
346x
198
                attributes = cachedAttributes
13x
199
            } else {
346x
200
                attributes = ChatLayoutAttributes(forCellWith: itemIndexPath)
333x
201
                cachedAttributeObjects[state]?[.cell]?[itemPath] = attributes
333x
202
            }
346x
203
            #if DEBUG
346x
204
            attributes.id = item.id
346x
205
            #endif
346x
206
            attributes.frame = itemFrame
346x
207
            attributes.indexPath = itemIndexPath
346x
208
            attributes.zIndex = 0
346x
209
            attributes.alignment = item.alignment
346x
210
        }
368x
211
        attributes.viewSize = layoutRepresentation.viewSize
368x
212
        attributes.adjustedContentInsets = layoutRepresentation.adjustedContentInset
368x
213
        attributes.visibleBoundsSize = layoutRepresentation.visibleBounds.size
368x
214
        attributes.layoutFrame = layoutRepresentation.layoutFrame
368x
215
        attributes.additionalInsets = layoutRepresentation.settings.additionalInsets
368x
216
        return attributes
368x
217
    }
368x
218
219
    func itemFrame(for itemPath: ItemPath, kind: ItemKind, at state: ModelState, isFinal: Bool = false) -> CGRect? {
220
        guard itemPath.section < layout(at: state).sections.count else {
221
            return nil
!
222
        }
223
        guard let item = self.item(for: itemPath, kind: kind, at: state) else {
224
            // This occurs when getting layout attributes for initial / final animations
!
225
            return nil
!
226
        }
227
228
        let section = layout(at: state).sections[itemPath.section]
229
        var itemFrame = item.frame
230
        let dx: CGFloat
231
        let visibleBounds = layoutRepresentation.visibleBounds
232
        let additionalInsets = layoutRepresentation.settings.additionalInsets
233
234
        switch item.alignment {
235
        case .leading:
236
            dx = additionalInsets.left
2x
237
        case .trailing:
238
            dx = visibleBounds.size.width - itemFrame.width - additionalInsets.right
2x
239
        case .center:
240
            let availableWidth = visibleBounds.size.width - additionalInsets.right - additionalInsets.left
5x
241
            dx = additionalInsets.left + availableWidth / 2 - itemFrame.width / 2
5x
242
        case .fullWidth:
243
            dx = additionalInsets.left
244
            itemFrame.size.width = layoutRepresentation.layoutFrame.size.width
245
        }
246
247
        itemFrame = itemFrame.offsetBy(dx: dx, dy: section.offsetY)
248
        if isFinal {
249
            itemFrame = offsetByCompensation(frame: itemFrame, at: itemPath, for: state, backward: true)
376x
250
        }
251
        return itemFrame
252
    }
253
254
    func itemPath(by itemId: UUID, kind: ItemKind, at state: ModelState) -> ItemPath? {
10300x
255
        return layout(at: state).itemPath(by: itemId, kind: kind)
10300x
256
    }
10300x
257
258
    func sectionIdentifier(for index: Int, at state: ModelState) -> UUID? {
2x
259
        guard index < layout(at: state).sections.count else {
2x
260
            // This occurs when getting layout attributes for initial / final animations
!
261
            return nil
!
262
        }
2x
263
        return layout(at: state).sections[index].id
2x
264
    }
2x
265
266
    func sectionIndex(for sectionIdentifier: UUID, at state: ModelState) -> Int? {
5x
267
        guard let sectionIndex = layout(at: state).sectionIndex(by: sectionIdentifier) else {
5x
268
            // This occurs when getting layout attributes for initial / final animations
!
269
            return nil
!
270
        }
5x
271
        return sectionIndex
5x
272
    }
5x
273
274
    func section(at index: Int, at state: ModelState) -> SectionModel {
10x
275
        guard index < layout(at: state).sections.count else {
10x
276
            preconditionFailure("Section index \(index) is bigger than the amount of sections \(layout(at: state).sections.count)")
!
277
        }
10x
278
        return layout(at: state).sections[index]
10x
279
    }
10x
280
281
    func itemIdentifier(for itemPath: ItemPath, kind: ItemKind, at state: ModelState) -> UUID? {
282
        guard itemPath.section < layout(at: state).sections.count else {
283
            // This occurs when getting layout attributes for initial / final animations
2x
284
            return nil
2x
285
        }
286
        let sectionModel = layout(at: state).sections[itemPath.section]
287
        switch kind {
288
        case .cell:
289
            guard itemPath.item < layout(at: state).sections[itemPath.section].items.count else {
290
                // This occurs when getting layout attributes for initial / final animations
3x
291
                return nil
3x
292
            }
293
            let rowModel = sectionModel.items[itemPath.item]
294
            return rowModel.id
295
        case .header, .footer:
296
            guard let item = item(for: ItemPath(item: 0, section: itemPath.section), kind: kind, at: state) else {
32x
297
                return nil
4x
298
            }
28x
299
            return item.id
28x
300
        }
301
302
    }
303
304
    func numberOfSections(at state: ModelState) -> Int {
4x
305
        return layout(at: state).sections.count
4x
306
    }
4x
307
308
    func numberOfItems(in sectionIndex: Int, at state: ModelState) -> Int {
28x
309
        return layout(at: state).sections[sectionIndex].items.count
28x
310
    }
28x
311
312
    func item(for itemPath: ItemPath, kind: ItemKind, at state: ModelState) -> ItemModel? {
313
        switch kind {
314
        case .header:
315
            guard itemPath.section < layout(at: state).sections.count,
61x
316
                itemPath.item == 0 else {
61x
317
                // This occurs when getting layout attributes for initial / final animations
1x
318
                return nil
1x
319
            }
60x
320
            guard let header = layout(at: state).sections[itemPath.section].header else {
60x
321
                return nil
3x
322
            }
57x
323
            return header
57x
324
        case .footer:
325
            guard itemPath.section < layout(at: state).sections.count,
46x
326
                itemPath.item == 0 else {
46x
327
                // This occurs when getting layout attributes for initial / final animations
1x
328
                return nil
1x
329
            }
45x
330
            guard let footer = layout(at: state).sections[itemPath.section].footer else {
45x
331
                return nil
1x
332
            }
44x
333
            return footer
44x
334
        case .cell:
335
            guard itemPath.section < layout(at: state).sections.count,
336
                itemPath.item < layout(at: state).sections[itemPath.section].count else {
337
                // This occurs when getting layout attributes for initial / final animations
!
338
                return nil
!
339
            }
340
            return layout(at: state).sections[itemPath.section].items[itemPath.item]
341
        }
342
    }
343
344
    func update(preferredSize: CGSize, alignment: ChatItemAlignment, for itemPath: ItemPath, kind: ItemKind, at state: ModelState) {
10000x
345
        guard var item = item(for: itemPath, kind: kind, at: state) else {
10000x
346
            assertionFailure("Internal inconsistency")
!
347
            return
!
348
        }
10000x
349
        var layout = self.layout(at: state)
10000x
350
        let previousFrame = item.frame
10000x
351
        cachedAttributesState = nil
10000x
352
        item.alignment = alignment
10000x
353
        item.calculatedSize = preferredSize
10000x
354
        item.calculatedOnce = true
10000x
355
10000x
356
        switch kind {
10000x
357
        case .header:
10000x
358
            layout.setAndAssemble(header: item, sectionIndex: itemPath.section)
3x
359
        case .footer:
10000x
360
            layout.setAndAssemble(footer: item, sectionIndex: itemPath.section)
3x
361
        case .cell:
10000x
362
            layout.setAndAssemble(item: item, sectionIndex: itemPath.section, itemIndex: itemPath.item)
10000x
363
        }
10000x
364
        storage[state] = layout
10000x
365
        let frameUpdateAction = CompensatingAction.frameUpdate(previousFrame: previousFrame, newFrame: item.frame)
10000x
366
        compensateOffsetIfNeeded(for: itemPath, kind: kind, action: frameUpdateAction)
10000x
367
    }
10000x
368
369
    func process(updateItems: [UICollectionViewUpdateItem]) {
42x
370
        batchUpdateCompensatingOffset = 0
42x
371
        proposedCompensatingOffset = 0
42x
372
        let updateItems = updateItems.sorted(by: {
373
            if $0.updateAction != $1.updateAction {
374
                return $0.updateAction.rawValue > $1.updateAction.rawValue
6x
375
            } else {
376
                return $0.indexPathAfterUpdate?.item ?? -1 < $1.indexPathAfterUpdate?.item ?? -1
377
            }
378
        })
!
379
42x
380
        var afterUpdateModel = layout(at: .beforeUpdate)
42x
381
        resetCachedAttributeObjects()
42x
382
42x
383
        updateItems.forEach { updateItem in
384
            let updateAction = updateItem.updateAction
385
            let indexPathBeforeUpdate = updateItem.indexPathBeforeUpdate
386
            let indexPathAfterUpdate = updateItem.indexPathAfterUpdate
387
388
            switch updateAction {
389
            case .none:
390
                break
!
391
            case .move:
392
                guard let indexPathBeforeUpdate = indexPathBeforeUpdate,
6x
393
                    let indexPathAfterUpdate = indexPathAfterUpdate else {
6x
394
                    assertionFailure("`indexPathBeforeUpdate` and `indexPathAfterUpdate` cannot be `nil` for a `.move` update action")
!
395
                    return
!
396
                }
6x
397
                if indexPathBeforeUpdate.item == NSNotFound, indexPathAfterUpdate.item == NSNotFound {
6x
398
                    let section = layout(at: .beforeUpdate).sections[indexPathBeforeUpdate.section]
2x
399
                    movedSectionsIndexes.insert(indexPathBeforeUpdate.section)
2x
400
                    afterUpdateModel.removeSection(by: section.id)
2x
401
                    afterUpdateModel.insertSection(section, at: indexPathAfterUpdate.section)
2x
402
                } else {
6x
403
                    let itemId = itemIdentifier(for: indexPathBeforeUpdate.itemPath, kind: .cell, at: .beforeUpdate)!
4x
404
                    let item = layout(at: .beforeUpdate).sections[indexPathBeforeUpdate.section].items[indexPathBeforeUpdate.item]
4x
405
                    movedIndexes.insert(indexPathBeforeUpdate)
4x
406
                    afterUpdateModel.removeItem(by: itemId)
4x
407
                    afterUpdateModel.insertItem(item, at: indexPathAfterUpdate)
4x
408
                }
409
            case .insert:
410
                guard let indexPath = indexPathAfterUpdate else {
411
                    assertionFailure("`indexPathAfterUpdate` cannot be `nil` for an `.insert` update action")
!
412
                    return
!
413
                }
414
415
                if indexPath.item == NSNotFound {
416
                    let items = (0..<layoutRepresentation.numberOfItems(in: indexPath.section)).map { index -> ItemModel in
350x
417
                        let itemIndexPath = IndexPath(item: index, section: indexPath.section)
350x
418
                        return ItemModel(with: layoutRepresentation.configuration(for: .cell, at: itemIndexPath.itemPath))
350x
419
                    }
350x
420
                    let header: ItemModel?
2x
421
                    if layoutRepresentation.shouldPresentHeader(at: indexPath.section) == true {
2x
422
                        let headerIndexPath = IndexPath(item: 0, section: indexPath.section)
1x
423
                        header = ItemModel(with: layoutRepresentation.configuration(for: .header, at: headerIndexPath.itemPath))
1x
424
                    } else {
2x
425
                        header = nil
1x
426
                    }
2x
427
                    let footer: ItemModel?
2x
428
                    if layoutRepresentation.shouldPresentFooter(at: indexPath.section) == true {
2x
429
                        let footerIndexPath = IndexPath(item: 0, section: indexPath.section)
2x
430
                        footer = ItemModel(with: layoutRepresentation.configuration(for: .footer, at: footerIndexPath.itemPath))
2x
431
                    } else {
2x
432
                        footer = nil
!
433
                    }
2x
434
                    let section = SectionModel(header: header, footer: footer, items: items, collectionLayout: layoutRepresentation)
2x
435
                    afterUpdateModel.insertSection(section, at: indexPath.section)
2x
436
                    insertedSectionsIndexes.insert(indexPath.section)
2x
437
                } else {
438
                    let item = ItemModel(with: layoutRepresentation.configuration(for: .cell, at: indexPath.itemPath))
439
                    insertedIndexes.insert(indexPath)
440
                    afterUpdateModel.insertItem(item, at: indexPath)
441
                }
442
            case .delete:
443
                guard let indexPath = indexPathBeforeUpdate else {
444
                    assertionFailure("`indexPathBeforeUpdate` cannot be `nil` for a `.delete` update action")
!
445
                    return
!
446
                }
447
448
                if indexPath.item == NSNotFound {
449
                    let section = layout(at: .beforeUpdate).sections[indexPath.section]
2x
450
                    deletedSectionsIndexes.insert(indexPath.section)
2x
451
                    afterUpdateModel.removeSection(by: section.id)
2x
452
                } else {
453
                    let itemId = itemIdentifier(for: indexPath.itemPath, kind: .cell, at: .beforeUpdate)!
454
                    afterUpdateModel.removeItem(by: itemId)
455
                    deletedIndexes.insert(indexPath)
456
                }
457
            case .reload:
458
                guard let indexPath = indexPathAfterUpdate else {
10300x
459
                    assertionFailure("`indexPathAfterUpdate` cannot be `nil` for a `.reload` update action")
!
460
                    return
!
461
                }
10300x
462
10300x
463
                if indexPath.item == NSNotFound {
10300x
464
                    reloadedSectionsIndexes.insert(indexPath.section)
5x
465
                    var section = layout(at: .beforeUpdate).sections[indexPath.section]
5x
466
5x
467
                    var header: ItemModel?
5x
468
                    if layoutRepresentation.shouldPresentHeader(at: indexPath.section) == true {
5x
469
                        let headerIndexPath = IndexPath(item: 0, section: indexPath.section)
3x
470
                        header = section.header ?? ItemModel(with: layoutRepresentation.configuration(for: .header, at: headerIndexPath.itemPath))
3x
471
                        header?.resetSize()
3x
472
                    } else {
5x
473
                        header = nil
2x
474
                    }
5x
475
                    section.set(header: header)
5x
476
5x
477
                    var footer: ItemModel?
5x
478
                    if layoutRepresentation.shouldPresentFooter(at: indexPath.section) == true {
5x
479
                        let footerIndexPath = IndexPath(item: 0, section: indexPath.section)
4x
480
                        footer = section.footer ?? ItemModel(with: layoutRepresentation.configuration(for: .footer, at: footerIndexPath.itemPath))
4x
481
                        footer?.resetSize()
4x
482
                    } else {
5x
483
                        footer = nil
1x
484
                    }
5x
485
                    section.set(footer: footer)
5x
486
5x
487
                    let oldItems = section.items
5x
488
                    let items: [ItemModel] = (0..<layoutRepresentation.numberOfItems(in: indexPath.section)).map { index in
550x
489
                        var newItem: ItemModel
550x
490
                        if index < oldItems.count {
550x
491
                            newItem = oldItems[index]
450x
492
                        } else {
550x
493
                            let itemIndexPath = IndexPath(item: index, section: indexPath.section)
100x
494
                            newItem = ItemModel(with: layoutRepresentation.configuration(for: .cell, at: itemIndexPath.itemPath))
100x
495
                        }
550x
496
                        newItem.resetSize()
550x
497
                        return newItem
550x
498
                    }
550x
499
                    section.set(items: items)
5x
500
                    afterUpdateModel.removeSection(for: indexPath.section)
5x
501
                    afterUpdateModel.insertSection(section, at: indexPath.section)
5x
502
                } else {
10300x
503
                    guard var item = self.item(for: indexPath.itemPath, kind: .cell, at: .beforeUpdate) else {
10300x
504
                        assertionFailure("Internal inconsistency")
!
505
                        return
!
506
                    }
10300x
507
                    item.resetSize()
10300x
508
10300x
509
                    afterUpdateModel.replaceItem(item, at: indexPath)
10300x
510
                    reloadedIndexes.insert(indexPath)
10300x
511
                }
512
            default:
513
                assertionFailure("Unexpected action to process")
!
514
            }
515
        }
516
42x
517
        afterUpdateModel = LayoutModel(sections: afterUpdateModel.sections.map { section -> SectionModel in
60x
518
            var section = section
60x
519
            section.assembleLayout()
60x
520
            return section
60x
521
        }, collectionLayout: layoutRepresentation)
60x
522
        afterUpdateModel.assembleLayout()
42x
523
        storage[.afterUpdate] = afterUpdateModel
42x
524
42x
525
        // Calculating potential content offset changes after the updates
42x
526
        insertedSectionsIndexes.sorted(by: { $0 < $1 }).forEach {
42x
527
            compensateOffsetOfSectionIfNeeded(for: $0, action: .insert)
2x
528
        }
2x
529
        reloadedSectionsIndexes.sorted(by: { $0 < $1 }).forEach {
42x
530
            let oldSection = self.section(at: $0, at: .beforeUpdate)
5x
531
            guard let newSectionIndex = self.sectionIndex(for: oldSection.id, at: .afterUpdate) else {
5x
532
                assertionFailure("Internal inconsistency")
!
533
                return
!
534
            }
5x
535
            let newSection = self.section(at: newSectionIndex, at: .afterUpdate)
5x
536
            compensateOffsetOfSectionIfNeeded(for: $0, action: .frameUpdate(previousFrame: oldSection.frame, newFrame: newSection.frame))
5x
537
        }
5x
538
        deletedSectionsIndexes.sorted(by: { $0 < $1 }).forEach {
42x
539
            compensateOffsetOfSectionIfNeeded(for: $0, action: .delete)
2x
540
        }
2x
541
42x
542
        reloadedIndexes.sorted(by: { $0 < $1 }).forEach {
543
            guard let oldItem = self.item(for: $0.itemPath, kind: .cell, at: .beforeUpdate),
10300x
544
                let newItemIndexPath = self.itemPath(by: oldItem.id, kind: .cell, at: .afterUpdate),
10300x
545
                let newItem = self.item(for: newItemIndexPath, kind: .cell, at: .afterUpdate) else {
10300x
546
                assertionFailure("Internal inconsistency")
!
547
                return
!
548
            }
10300x
549
            compensateOffsetIfNeeded(for: $0.itemPath, kind: .cell, action: .frameUpdate(previousFrame: oldItem.frame, newFrame: newItem.frame))
10300x
550
        }
10300x
551
42x
552
        insertedIndexes.sorted(by: { $0 < $1 }).forEach {
1870000x
553
            compensateOffsetIfNeeded(for: $0.itemPath, kind: .cell, action: .insert)
554
        }
555
        deletedIndexes.sorted(by: { $0 < $1 }).forEach {
1840000x
556
            compensateOffsetIfNeeded(for: $0.itemPath, kind: .cell, action: .delete)
557
        }
558
42x
559
        totalProposedCompensatingOffset = proposedCompensatingOffset
42x
560
    }
42x
561
562
    func commitUpdates() {
12x
563
        insertedIndexes = []
12x
564
        insertedSectionsIndexes = []
12x
565
12x
566
        reloadedIndexes = []
12x
567
        reloadedSectionsIndexes = []
12x
568
12x
569
        movedIndexes = []
12x
570
        movedSectionsIndexes = []
12x
571
12x
572
        deletedIndexes = []
12x
573
        deletedSectionsIndexes = []
12x
574
12x
575
        storage[.beforeUpdate] = layout(at: .afterUpdate)
12x
576
        storage[.afterUpdate] = nil
12x
577
12x
578
        totalProposedCompensatingOffset = 0
12x
579
12x
580
        cachedAttributeObjects[.beforeUpdate] = cachedAttributeObjects[.afterUpdate]
12x
581
        resetCachedAttributeObjects(at: .afterUpdate)
12x
582
    }
12x
583
584
    func contentSize(for state: ModelState) -> CGSize {
1x
585
        let contentHeight = self.contentHeight(at: state)
1x
586
        guard contentHeight != 0 else {
1x
587
            return .zero
!
588
        }
1x
589
        // This is a workaround for `layoutAttributesForElementsInRect:` not getting invoked enough
1x
590
        // times if `collectionViewContentSize.width` is not smaller than the width of the collection
1x
591
        // view, minus horizontal insets. This results in visual defects when performing batch
1x
592
        // updates. To work around this, we subtract 0.0001 from our content size width calculation;
1x
593
        // this small decrease in `collectionViewContentSize.width` is enough to work around the
1x
594
        // incorrect, internal collection view `CGRect` checks, without introducing any visual
1x
595
        // differences for elements in the collection view.
1x
596
        // See https://openradar.appspot.com/radar?id=5025850143539200 for more details.
1x
597
        let contentSize = CGSize(width: layoutRepresentation.visibleBounds.size.width - 0.0001, height: contentHeight)
1x
598
        return contentSize
1x
599
    }
1x
600
601
    func offsetByTotalCompensation(attributes: UICollectionViewLayoutAttributes?, for state: ModelState, backward: Bool = false) {
!
602
        guard layoutRepresentation.keepContentOffsetAtBottomOnBatchUpdates,
!
603
            state == .afterUpdate,
!
604
            let attributes = attributes else {
!
605
            return
!
606
        }
!
607
        if backward, isLayoutBiggerThanVisibleBounds(at: .afterUpdate) {
!
608
            attributes.frame = attributes.frame.offsetBy(dx: 0, dy: totalProposedCompensatingOffset * -1)
!
609
        } else if !backward, isLayoutBiggerThanVisibleBounds(at: .afterUpdate) {
!
610
            attributes.frame = attributes.frame.offsetBy(dx: 0, dy: totalProposedCompensatingOffset)
!
611
        }
!
612
    }
!
613
614
    func layout(at state: ModelState) -> LayoutModel {
1440000x
615
        guard let layout = storage[state] else {
1440000x
616
            assertionFailure("Internal inconsistency. Layout at \(state) is missing.")
!
617
            return LayoutModel(sections: [], collectionLayout: layoutRepresentation)
!
618
        }
1440000x
619
        return layout
1440000x
620
    }
1440000x
621
622
    func isLayoutBiggerThanVisibleBounds(at state: ModelState, withFullCompensation: Bool = false) -> Bool {
623
        let visibleBoundsHeight = layoutRepresentation.visibleBounds.height + (withFullCompensation ? batchUpdateCompensatingOffset + proposedCompensatingOffset : 0)
624
        return contentHeight(at: state).rounded() > visibleBoundsHeight.rounded()
625
    }
626
627
    private func allAttributes(at state: ModelState, visibleRect: CGRect? = nil) -> [ChatLayoutAttributes] {
4x
628
        let layout = self.layout(at: state)
4x
629
4x
630
        if let visibleRect = visibleRect {
4x
631
            enum TraverseState {
4x
632
                case notFound
4x
633
                case found
4x
634
                case done
4x
635
            }
4x
636
4x
637
            var traverseState: TraverseState = .notFound
4x
638
4x
639
            func check(rect: CGRect) -> Bool {
60x
640
                switch traverseState {
60x
641
                case .notFound:
60x
642
                    if visibleRect.intersects(rect) {
4x
643
                        traverseState = .found
4x
644
                        return true
4x
645
                    } else {
4x
646
                        return false
!
647
                    }
60x
648
                case .found:
60x
649
                    if visibleRect.intersects(rect) {
52x
650
                        return true
48x
651
                    } else {
48x
652
                        if rect.minY > visibleRect.maxY + batchUpdateCompensatingOffset + proposedCompensatingOffset {
4x
653
                            traverseState = .done
4x
654
                        }
4x
655
                        return false
4x
656
                    }
60x
657
                case .done:
60x
658
                    return false
4x
659
                }
60x
660
            }
60x
661
4x
662
            var allRects = [(frame: CGRect, indexPath: ItemPath, kind: ItemKind)]()
4x
663
            // I dont think there can be more then a 200 elements on the screen simultaneously
4x
664
            allRects.reserveCapacity(200)
4x
665
            let skipIndex = 100
4x
666
            for sectionIndex in 0..<layout.sections.count {
12x
667
                let section = layout.sections[sectionIndex]
12x
668
                let sectionPath = ItemPath(item: 0, section: sectionIndex)
12x
669
                if let headerFrame = itemFrame(for: sectionPath, kind: .header, at: state, isFinal: true),
12x
670
                    check(rect: headerFrame) {
12x
671
                    allRects.append((frame: headerFrame, indexPath: sectionPath, kind: .header))
8x
672
                }
12x
673
                guard traverseState != .done else {
12x
674
                    break
4x
675
                }
8x
676
8x
677
                var startingIndex = 0
8x
678
                // Lets try to skip some calculations as visible rect most often is at the bottom of the layout
8x
679
                if traverseState == .notFound {
8x
680
                    var iterationIndex = skipIndex
!
681
                    while iterationIndex < section.items.count {
!
682
                        let itemPath = ItemPath(item: iterationIndex, section: sectionIndex)
!
683
                        let itemFrame = self.itemFrame(for: itemPath, kind: .cell, at: state, isFinal: true)
!
684
                        if itemFrame == nil || itemFrame.map({ $0.maxY < visibleRect.minY ? true : false }) == true {
!
685
                            startingIndex = iterationIndex
!
686
                            iterationIndex += skipIndex
!
687
                            continue
!
688
                        } else {
!
689
                            break
!
690
                        }
!
691
                    }
!
692
                }
8x
693
                if startingIndex < section.items.count {
8x
694
                    for itemIndex in startingIndex..<section.items.count {
40x
695
                        let itemPath = ItemPath(item: itemIndex, section: sectionIndex)
40x
696
                        if let itemFrame = self.itemFrame(for: itemPath, kind: .cell, at: state, isFinal: true),
40x
697
                            check(rect: itemFrame) {
40x
698
                            if state == .beforeUpdate || isAnimatedBoundsChange {
40x
699
                                allRects.append((frame: itemFrame, indexPath: itemPath, kind: .cell))
40x
700
                            } else {
40x
701
                                var itemWasVisibleBefore: Bool {
!
702
                                    guard let itemIdentifier = self.itemIdentifier(for: itemPath, kind: .cell, at: .afterUpdate),
!
703
                                        let initialIndexPath = self.itemPath(by: itemIdentifier, kind: .cell, at: .beforeUpdate),
!
704
                                        let item = self.item(for: initialIndexPath, kind: .cell, at: .beforeUpdate),
!
705
                                        item.calculatedOnce == true,
!
706
                                        let itemFrame = self.itemFrame(for: initialIndexPath, kind: .cell, at: .beforeUpdate, isFinal: false),
!
707
                                        itemFrame.intersects(layoutRepresentation.visibleBounds.offsetBy(dx: 0, dy: -totalProposedCompensatingOffset)) else {
!
708
                                        return false
!
709
                                    }
!
710
                                    return true
!
711
                                }
!
712
                                var itemWillBeVisible: Bool {
!
713
                                    let offsetVisibleBounds = layoutRepresentation.visibleBounds.offsetBy(dx: 0, dy: proposedCompensatingOffset + batchUpdateCompensatingOffset)
!
714
                                    if insertedIndexes.contains(itemPath.indexPath),
!
715
                                        let itemFrame = self.itemFrame(for: itemPath, kind: .cell, at: state, isFinal: true),
!
716
                                        itemFrame.intersects(offsetVisibleBounds) {
!
717
                                        return true
!
718
                                    }
!
719
                                    if let itemIdentifier = self.itemIdentifier(for: itemPath, kind: .cell, at: .afterUpdate),
!
720
                                        let initialIndexPath = self.itemPath(by: itemIdentifier, kind: .cell, at: .beforeUpdate)?.indexPath,
!
721
                                        movedIndexes.contains(initialIndexPath) || reloadedIndexes.contains(initialIndexPath),
!
722
                                        let itemFrame = self.itemFrame(for: itemPath, kind: .cell, at: state, isFinal: true),
!
723
                                        itemFrame.intersects(offsetVisibleBounds) {
!
724
                                        return true
!
725
                                    }
!
726
                                    return false
!
727
                                }
!
728
                                if itemWillBeVisible || itemWasVisibleBefore {
!
729
                                    allRects.append((frame: itemFrame, indexPath: itemPath, kind: .cell))
!
730
                                }
!
731
                            }
40x
732
                        }
40x
733
                        guard traverseState != .done else {
40x
734
                            break
!
735
                        }
40x
736
                    }
40x
737
                }
8x
738
8x
739
                if let footerFrame = itemFrame(for: sectionPath, kind: .footer, at: state, isFinal: true),
8x
740
                    check(rect: footerFrame) {
8x
741
                    allRects.append((frame: footerFrame, indexPath: sectionPath, kind: .footer))
4x
742
                }
8x
743
            }
8x
744
4x
745
            return allRects.compactMap { frame, path, kind -> ChatLayoutAttributes? in
52x
746
                return self.itemAttributes(for: path, kind: kind, predefinedFrame: frame, at: state)
52x
747
            }
52x
748
        } else {
4x
749
            var attributes = [ChatLayoutAttributes]()
!
750
            attributes.reserveCapacity(layout.sections.count * 1000)
!
751
            layout.sections.enumerated().forEach { sectionIndex, section in
!
752
                let sectionPath = ItemPath(item: 0, section: sectionIndex)
!
753
                if let headerAttributes = self.itemAttributes(for: sectionPath, kind: .header, at: state) {
!
754
                    attributes.append(headerAttributes)
!
755
                }
!
756
                if let footerAttributes = self.itemAttributes(for: sectionPath, kind: .footer, at: state) {
!
757
                    attributes.append(footerAttributes)
!
758
                }
!
759
                section.items.enumerated().forEach { itemIndex, _ in
!
760
                    let itemPath = ItemPath(item: itemIndex, section: sectionIndex)
!
761
                    if let itemAttributes = self.itemAttributes(for: itemPath, kind: .cell, at: state) {
!
762
                        attributes.append(itemAttributes)
!
763
                    }
!
764
                }
!
765
            }
!
766
!
767
            return attributes
!
768
        }
!
769
    }
!
770
771
    private func compensateOffsetIfNeeded(for itemPath: ItemPath, kind: ItemKind, action: CompensatingAction) {
772
        guard layoutRepresentation.keepContentOffsetAtBottomOnBatchUpdates else {
773
            return
!
774
        }
775
        let minY = (layoutRepresentation.visibleBounds.lowerPoint.y + batchUpdateCompensatingOffset + proposedCompensatingOffset).rounded()
776
        switch action {
777
        case .insert:
778
            guard isLayoutBiggerThanVisibleBounds(at: .afterUpdate),
779
                let itemFrame = itemFrame(for: itemPath, kind: kind, at: .afterUpdate) else {
780
                return
2x
781
            }
782
            if itemFrame.minY.rounded() - layoutRepresentation.settings.interItemSpacing <= minY {
783
                proposedCompensatingOffset += itemFrame.height + layoutRepresentation.settings.interItemSpacing
784
            }
785
        case let .frameUpdate(previousFrame, newFrame):
786
            guard isLayoutBiggerThanVisibleBounds(at: .afterUpdate, withFullCompensation: true) else {
20300x
787
                return
10000x
788
            }
10300x
789
            if newFrame.minY.rounded() <= minY {
10300x
790
                batchUpdateCompensatingOffset += newFrame.height - previousFrame.height
104x
791
            }
792
        case .delete:
793
            guard isLayoutBiggerThanVisibleBounds(at: .beforeUpdate),
794
                let deletedFrame = itemFrame(for: itemPath, kind: kind, at: .beforeUpdate) else {
795
                return
4x
796
            }
797
            if deletedFrame.minY.rounded() <= minY {
798
                // Changing content offset for deleted items using `invalidateLayout(with:) causes UI glitches.
40x
799
                // So we are using targetContentOffset(forProposedContentOffset:) which is going to be called after.
40x
800
                proposedCompensatingOffset -= (deletedFrame.height + layoutRepresentation.settings.interItemSpacing)
40x
801
            }
802
        }
803
804
    }
805
806
    private func compensateOffsetOfSectionIfNeeded(for sectionIndex: Int, action: CompensatingAction) {
9x
807
        guard layoutRepresentation.keepContentOffsetAtBottomOnBatchUpdates else {
9x
808
            return
!
809
        }
9x
810
        let minY = (layoutRepresentation.visibleBounds.lowerPoint.y + batchUpdateCompensatingOffset + proposedCompensatingOffset).rounded()
9x
811
        switch action {
9x
812
        case .insert:
9x
813
            guard isLayoutBiggerThanVisibleBounds(at: .afterUpdate),
2x
814
                sectionIndex < layout(at: .afterUpdate).sections.count else {
2x
815
                return
!
816
            }
2x
817
            let section = layout(at: .afterUpdate).sections[sectionIndex]
2x
818
2x
819
            if section.offsetY.rounded() - layoutRepresentation.settings.interSectionSpacing <= minY {
2x
820
                proposedCompensatingOffset += section.height + layoutRepresentation.settings.interSectionSpacing
!
821
            }
9x
822
        case let .frameUpdate(previousFrame, newFrame):
9x
823
            guard sectionIndex < layout(at: .afterUpdate).sections.count,
5x
824
                isLayoutBiggerThanVisibleBounds(at: .afterUpdate, withFullCompensation: true) else {
5x
825
                return
!
826
            }
5x
827
            if newFrame.minY.rounded() <= minY {
5x
828
                batchUpdateCompensatingOffset += newFrame.height - previousFrame.height
2x
829
            }
9x
830
        case .delete:
9x
831
            guard isLayoutBiggerThanVisibleBounds(at: .afterUpdate),
2x
832
                sectionIndex < layout(at: .afterUpdate).sections.count else {
2x
833
                return
1x
834
            }
1x
835
            let section = layout(at: .beforeUpdate).sections[sectionIndex]
1x
836
            if section.locationHeight.rounded() <= minY {
1x
837
                // Changing content offset for deleted items using `invalidateLayout(with:) causes UI glitches.
!
838
                // So we are using targetContentOffset(forProposedContentOffset:) which is going to be called after.
!
839
                proposedCompensatingOffset -= (section.height + layoutRepresentation.settings.interSectionSpacing)
!
840
            }
9x
841
        }
9x
842
9x
843
    }
9x
844
845
    private func offsetByCompensation(frame: CGRect,
846
                                      at itemPath: ItemPath,
847
                                      for state: ModelState,
848
                                      backward: Bool = false) -> CGRect {
376x
849
        guard layoutRepresentation.keepContentOffsetAtBottomOnBatchUpdates,
376x
850
            state == .afterUpdate,
376x
851
            isLayoutBiggerThanVisibleBounds(at: .afterUpdate) else {
376x
852
            return frame
376x
853
        }
376x
854
        return frame.offsetBy(dx: 0, dy: proposedCompensatingOffset * (backward ? -1 : 1))
!
855
    }
376x
856
857
}