Slather logo

Coverage for "StateController.swift" : 4.45%

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