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