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