Slather logo

Coverage for "StateController.swift" : 78.59%

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