From 56b8aee5064b5a7533897134a4a01db166e4b74f Mon Sep 17 00:00:00 2001 From: Eugene Kazaev Date: Tue, 10 Oct 2023 16:09:46 +0100 Subject: [PATCH] Improved updates processing --- .../Core/CollectionViewChatLayout.swift | 91 +----- .../Classes/Core/Model/LayoutModel.swift | 2 +- .../Classes/Core/Model/SectionModel.swift | 3 +- .../Classes/Core/Model/StateController.swift | 285 ++++++++++-------- .../Controller/DefaultChatController.swift | 14 +- .../Model/DefaultRandomDataProvider.swift | 6 +- .../Chat/View/ChatViewController.swift | 38 +-- .../DefaultChatCollectionDataSource.swift | 21 +- 8 files changed, 207 insertions(+), 253 deletions(-) diff --git a/ChatLayout/Classes/Core/CollectionViewChatLayout.swift b/ChatLayout/Classes/Core/CollectionViewChatLayout.swift index b43a2f56..03ef3ad3 100644 --- a/ChatLayout/Classes/Core/CollectionViewChatLayout.swift +++ b/ChatLayout/Classes/Core/CollectionViewChatLayout.swift @@ -538,18 +538,8 @@ open class CollectionViewChatLayout: UICollectionViewLayout { let layoutAttributesForPendingAnimation = attributesForPendingAnimations[preferredMessageAttributes.kind]?[preferredAttributesItemPath] let newItemSize = itemSize(with: preferredMessageAttributes) - let newInterItemSpacing: CGFloat - let newItemAlignment: ChatItemAlignment -// if controller.reloadedIndexes.contains(preferredMessageAttributes.indexPath) || controller.reconfiguredIndexes.contains(preferredMessageAttributes.indexPath), -// controller.insertedIndexes.contains(preferredMessageAttributes.indexPath) || controller.insertedSectionsIndexes.contains(preferredMessageAttributes.indexPath.section) { - newItemAlignment = alignment(for: preferredMessageAttributes.kind, at: preferredMessageAttributes.indexPath) - newInterItemSpacing = interItemSpacing(for: preferredMessageAttributes.kind, at: preferredMessageAttributes.indexPath) - print("A: \(preferredMessageAttributes.indexPath.item) \(newItemAlignment) - \(newInterItemSpacing)") -// } else { -// newItemAlignment = preferredMessageAttributes.alignment -// newInterItemSpacing = preferredMessageAttributes.interItemSpacing -// print("b: \(preferredMessageAttributes.indexPath.item) \(newItemAlignment) - \(newInterItemSpacing)") -// } + let newItemAlignment = alignment(for: preferredMessageAttributes.kind, at: preferredMessageAttributes.indexPath) + let newInterItemSpacing = interItemSpacing(for: preferredMessageAttributes.kind, at: preferredMessageAttributes.indexPath) controller.update(preferredSize: newItemSize, alignment: newItemAlignment, interItemSpacing: newInterItemSpacing, @@ -700,55 +690,9 @@ open class CollectionViewChatLayout: UICollectionViewLayout { // MARK: Responding to Collection View Updates - private class MockUICollectionViewUpdateItem: UICollectionViewUpdateItem { - - // swiftlint:disable identifier_name - var _indexPathBeforeUpdate: IndexPath? - var _indexPathAfterUpdate: IndexPath? - var _updateAction: Action - // swiftlint:enable identifier_name - - init(indexPathBeforeUpdate: IndexPath?, indexPathAfterUpdate: IndexPath?, action: Action) { - _indexPathBeforeUpdate = indexPathBeforeUpdate - _indexPathAfterUpdate = indexPathAfterUpdate - _updateAction = action - super.init() - } - - override var indexPathBeforeUpdate: IndexPath? { - _indexPathBeforeUpdate - } - - override var indexPathAfterUpdate: IndexPath? { - _indexPathAfterUpdate - } - - override var updateAction: Action { - _updateAction - } - - } - /// Notifies the layout object that the contents of the collection view are about to change. open override func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) { - print("\(updateItems)") -// let updateItems = updateItems.reduce(into: [UICollectionViewUpdateItem](), { result, item in -// switch item.updateAction { -// case .delete: -// result.append(MockUICollectionViewUpdateItem(indexPathBeforeUpdate: item.indexPathBeforeUpdate, indexPathAfterUpdate: item.indexPathAfterUpdate, action: .delete)) -// case .insert: -// result.append(MockUICollectionViewUpdateItem(indexPathBeforeUpdate: item.indexPathBeforeUpdate, indexPathAfterUpdate: item.indexPathAfterUpdate, action: .insert)) -// case .reload: -// result.append(MockUICollectionViewUpdateItem(indexPathBeforeUpdate: item.indexPathBeforeUpdate, indexPathAfterUpdate: item.indexPathAfterUpdate, action: .reload)) -// case .move: -// result.append(MockUICollectionViewUpdateItem(indexPathBeforeUpdate: item.indexPathBeforeUpdate, indexPathAfterUpdate: nil, action: .delete)) -// result.append(MockUICollectionViewUpdateItem(indexPathBeforeUpdate: nil, indexPathAfterUpdate: item.indexPathAfterUpdate, action: .insert)) -// case .none: -// break -// @unknown default: -// break -// } -// }) + print("\(#function) \(updateItems)") var changeItems = updateItems.compactMap { ChangeItem(with: $0) } changeItems.append(contentsOf: reconfigureItemsIndexPaths.map { .itemReconfigure(itemIndexPath: $0) }) controller.process(changeItems: changeItems) @@ -838,6 +782,7 @@ open class CollectionViewChatLayout: UICollectionViewLayout { attributes = controller.itemAttributes(for: itemPath, kind: .cell, at: .beforeUpdate) } + print("\(#function) \(attributes)") return attributes } @@ -887,6 +832,7 @@ open class CollectionViewChatLayout: UICollectionViewLayout { attributes = controller.itemAttributes(for: itemPath, kind: .cell, at: .beforeUpdate) } + print("\(#function) \(attributes)") return attributes } @@ -1109,30 +1055,3 @@ extension CollectionViewChatLayout { } } - -extension UICollectionViewUpdateItem: Comparable { - public static func < (lhs: UICollectionViewUpdateItem, rhs: UICollectionViewUpdateItem) -> Bool { - switch (lhs.updateAction, rhs.updateAction) { - case (.delete, .delete): - if let lIndexPathBeforeUpdate = lhs.indexPathBeforeUpdate, - let rIndexPathBeforeUpdate = rhs.indexPathBeforeUpdate { - if lIndexPathBeforeUpdate.item == NSNotFound, rIndexPathBeforeUpdate.item == NSNotFound { - return lIndexPathBeforeUpdate.section > rIndexPathBeforeUpdate.section - } else { - return lIndexPathBeforeUpdate > rIndexPathBeforeUpdate - } - } - default: - if let lIndexPathBeforeUpdate = lhs.indexPathBeforeUpdate, - let rIndexPathBeforeUpdate = rhs.indexPathBeforeUpdate { - if lIndexPathBeforeUpdate.item == NSNotFound, rIndexPathBeforeUpdate.item == NSNotFound { - return lIndexPathBeforeUpdate.section < rIndexPathBeforeUpdate.section - } else { - return lIndexPathBeforeUpdate < rIndexPathBeforeUpdate - } - } - } - return (lhs.indexPathBeforeUpdate ?? IndexPath(item: 0, section: 0)) < (rhs.indexPathBeforeUpdate ?? IndexPath(item: 0, section: 0)) - } - -} diff --git a/ChatLayout/Classes/Core/Model/LayoutModel.swift b/ChatLayout/Classes/Core/Model/LayoutModel.swift index 744aa130..ddceb595 100644 --- a/ChatLayout/Classes/Core/Model/LayoutModel.swift +++ b/ChatLayout/Classes/Core/Model/LayoutModel.swift @@ -47,7 +47,7 @@ final class LayoutModel { for sectionIndex in 0.. { items.withUnsafeMutableBufferPointer { directlyMutableItems in for rowIndex in 0.. { let previousFrame = item.frame let previousInterItemSpacing = item.interItemSpacing cachedAttributesState = nil - if item.alignment != alignment { - print("ALIGNMENT CHANGE: \(itemPath.item) \(item.id) \(item.alignment) -> \(alignment) ") - } - if previousFrame.size != preferredSize { - print("SIZE CHANGE: \(itemPath.item) \(item.id) \(previousFrame.size) -> \(preferredSize) ") - } item.alignment = alignment item.calculatedSize = preferredSize item.calculatedOnce = true @@ -490,13 +484,68 @@ final class StateController { } } + let isLastItemInSection = isLastItemInSection1(itemPath, at: state) let frameUpdateAction = CompensatingAction.frameUpdate(previousFrame: previousFrame, newFrame: item.frame, - previousSpacing: previousInterItemSpacing, - newSpacing: interItemSpacing) + previousSpacing: isLastItemInSection ? 0 : previousInterItemSpacing, + newSpacing: isLastItemInSection ? 0 : interItemSpacing) compensateOffsetIfNeeded(for: itemPath, kind: kind, action: frameUpdateAction) } + struct ItemToRestore { + var globalIndex: Int + var kind: ItemKind + var offset: CGFloat + } + + private func numberOfItemsBeforeSection(_ sectionIndex: Int, state: ModelState, layout: LayoutModel? = nil) -> Int { + let layout = layout ?? self.layout(at: state) + var total = 0 + for index in 0..? = nil) -> Int { + switch kind { + case .header: + return numberOfItemsBeforeSection(itemPath.section, state: state, layout: layout) + case .footer: + return numberOfItemsBeforeSection(itemPath.section, state: state, layout: layout) + case .cell: + return numberOfItemsBeforeSection(itemPath.section, state: state, layout: layout) + itemPath.item + } + } + + private func itemPathFor(_ globalIndex: Int, kind: ItemKind, state: ModelState) -> ItemPath { + let layout = layout(at: state) + var sectionIndex: Int = 0 + var itemsCount = 0 + for index in 0.. globalIndex { + break + } + itemsCount = countIncludingThisSection + } + switch kind { + case .header, .footer: + return ItemPath(item: 0, section: sectionIndex) + case .cell: + return ItemPath(item: globalIndex - itemsCount, section: sectionIndex) + } + } + func process(changeItems: [ChangeItem]) { func applyConfiguration(_ configuration: ItemModel.Configuration, to item: inout ItemModel) { item.alignment = configuration.alignment @@ -508,11 +557,14 @@ final class StateController { item.resetSize() } } - print("BEFORE MODIFICATION \(changeItems.map { "\(String(describing: $0))" }):") - if !layout(at: .beforeUpdate).sections.isEmpty { - print("\(section(at: 0, at: .beforeUpdate).items.enumerated().map { "\($0.offset): \($0.element.id) \($0.element.alignment) \($0.element.interItemSpacing)\n" }.joined())") + var itemToRestore: ItemToRestore? = nil + if let lastVisibleAttribute = allAttributes(at: .beforeUpdate, visibleRect: layoutRepresentation.visibleBounds).last, + let item = item(for: lastVisibleAttribute.indexPath.itemPath, kind: lastVisibleAttribute.kind, at: .beforeUpdate) { + itemToRestore = ItemToRestore(globalIndex: globalIndexFor(lastVisibleAttribute.indexPath.itemPath, kind: lastVisibleAttribute.kind, state: .beforeUpdate), + kind: lastVisibleAttribute.kind, + offset: (item.frame.maxY - layoutRepresentation.visibleBounds.maxY).rounded()) } - + print("ORIGINAL ITEM TO RESTORE: \(itemToRestore)") batchUpdateCompensatingOffset = 0 proposedCompensatingOffset = 0 @@ -528,36 +580,46 @@ final class StateController { var deletedItemsIndexesArray = [IndexPath]() var insertedItemsIndexesArray = [(IndexPath, ItemModel?)]() + var visibleBoundsBeforeUpdate = layoutRepresentation.visibleBounds + changeItems.forEach { item in switch item { case let .sectionReload(sectionIndex): reloadedSectionsIndexes.insert(sectionIndex) + reloadedSectionsIndexesArray.append(sectionIndex) case let .itemReload(itemIndexPath: indexPath): reloadedIndexes.insert(indexPath) + reloadedItemsIndexesArray.append(indexPath) case let .itemReconfigure(itemIndexPath: indexPath): reconfiguredIndexes.insert(indexPath) + reloadedItemsIndexesArray.append(indexPath) case let .sectionDelete(sectionIndex): deletedSectionsIndexes.insert(sectionIndex) deletedSectionsIndexesArray.append(sectionIndex) case let .itemDelete(itemIndexPath: indexPath): deletedIndexes.insert(indexPath) + deletedItemsIndexesArray.append(indexPath) case let .sectionInsert(sectionIndex): insertedSectionsIndexes.insert(sectionIndex) + insertedSectionsIndexesArray.append((sectionIndex, nil)) case let .itemInsert(itemIndexPath: indexPath): insertedIndexes.insert(indexPath) + insertedItemsIndexesArray.append((indexPath, nil)) case let .sectionMove(initialSectionIndex, finalSectionIndex): movedSectionsIndexes.insert(initialSectionIndex) + let original = layoutBeforeUpdate.sections[initialSectionIndex] deletedSectionsIndexesArray.append(initialSectionIndex) insertedSectionsIndexesArray.append((finalSectionIndex, original)) case let .itemMove(initialItemIndexPath, finalItemIndexPath): movedIndexes.insert(initialItemIndexPath) + let original = layoutBeforeUpdate.sections[initialItemIndexPath.section].items[initialItemIndexPath.item] deletedItemsIndexesArray.append(initialItemIndexPath) insertedItemsIndexesArray.append((finalItemIndexPath, original)) @@ -657,10 +719,12 @@ final class StateController { assertionFailure("Item at index path (\(indexPath.section) - \(indexPath.item)) does not exist.") return } + let oldHeight = item.frame.height let configuration = layoutRepresentation.configuration(for: .cell, at: indexPath) print("RELOADED \(indexPath.item): \(item.id) \(item.alignment) \(item.interItemSpacing)\n") applyConfiguration(configuration, to: &item) afterUpdateModel.replaceItem(item, at: indexPath) + visibleBoundsBeforeUpdate.offsettingBy(dx: 0, dy: item.frame.height - oldHeight) } deletedItemsIndexesArray.forEach { indexPath in @@ -668,17 +732,44 @@ final class StateController { let item = item(for: indexPath.itemPath, kind: .cell, at: .beforeUpdate)! afterUpdateModel.removeItem(by: itemId) print("DELETED \(indexPath.item): \(item.id) \(item.alignment) \(item.interItemSpacing)\n") + let globalIndex = globalIndexFor(indexPath.itemPath, kind: .cell, state: .beforeUpdate) + if let localItemToRestore = itemToRestore, + localItemToRestore.kind == .cell, + (localItemToRestore.globalIndex >= globalIndex) { + if localItemToRestore.globalIndex != 0 { + itemToRestore?.globalIndex = localItemToRestore.globalIndex - 1 + } else { + itemToRestore = nil + } + print("NEW ITEM TO RESTORE AFTER DELETE: \(itemToRestore)") + } } insertedItemsIndexesArray.forEach { indexPath, item in + let insertedItem: ItemModel if let item { + insertedItem = item afterUpdateModel.insertItem(item, at: indexPath) + visibleBoundsBeforeUpdate.offsettingBy(dx: 0, dy: item.frame.height) print("MOVED \(indexPath.item): \(item.id) \(item.alignment) \(item.interItemSpacing)\n") } else { let item = ItemModel(with: layoutRepresentation.configuration(for: .cell, at: indexPath)) + insertedItem = item print("INSERTED \(indexPath.item): \(item.id) \(item.alignment) \(item.interItemSpacing)\n") + visibleBoundsBeforeUpdate.offsettingBy(dx: 0, dy: item.frame.height) afterUpdateModel.insertItem(item, at: indexPath) } + let globalIndex = globalIndexFor(indexPath.itemPath, kind: .cell, state: .afterUpdate, layout: afterUpdateModel) + if let localItemToRestore = itemToRestore { + if localItemToRestore.kind == .cell, + localItemToRestore.globalIndex + 1 >= globalIndex { + itemToRestore?.globalIndex = localItemToRestore.globalIndex + 1 + print("NEW ITEM TO RESTORE AFTER INSERT: \(itemToRestore)") + } + } else { + itemToRestore = ItemToRestore(globalIndex: globalIndex, kind: .cell, offset: 0) + print("NEW ITEM TO RESTORE AFTER INSERT: \(itemToRestore)") + } } var afterUpdateModelSections = afterUpdateModel.sections @@ -692,126 +783,28 @@ final class StateController { layoutAfterUpdate = afterUpdateModel + print("BEFORE MODIFICATION \(changeItems.map { "\(String(describing: $0))" }):") + if !layout(at: .beforeUpdate).sections.isEmpty { + print("\(section(at: 0, at: .beforeUpdate).items.enumerated().map { "\($0.offset): \($0.element.id) \($0.element.alignment) \($0.element.interItemSpacing)\n" }.joined())") + } + print("AFTER MODIFICATION \(changeItems.map { "\(String(describing: $0))" }):") if !layout(at: .afterUpdate).sections.isEmpty { print("\(section(at: 0, at: .afterUpdate).items.enumerated().map { "\($0.offset): \($0.element.id) \($0.element.alignment) \($0.element.interItemSpacing)\n" }.joined())") } - - let visibleBounds = layoutRepresentation.visibleBounds - - func isLastSectionInLayout(_ index: Int, at state: ModelState) -> Bool { - let layout = layout(at: state) - guard index < layout.sections.count - 1 else { - return false - } - return true - } - - func isLastItemInSection(_ itemPath: ItemPath, at state: ModelState) -> Bool { - let layout = layout(at: state) - guard itemPath.section < layout.sections.count, - itemPath.item < layout.sections[itemPath.section].items.count - 1 else { - // This occurs when getting layout attributes for initial / final animations - return false - } - return true - } - + print("") // Calculating potential content offset changes after the updates - insertedSectionsIndexes.sorted(by: { $0 < $1 }).forEach { - let section = section(at: $0, at: .afterUpdate) - compensateOffsetOfSectionIfNeeded(for: $0, - action: .insert(spacing: isLastSectionInLayout($0, at: .afterUpdate) ? 0 : section.interSectionSpacing), - visibleBounds: visibleBounds) - } - reloadedSectionsIndexes.sorted(by: { $0 < $1 }).forEach { - let oldSection = section(at: $0, at: .beforeUpdate) - guard let newSectionIndex = sectionIndex(for: oldSection.id, at: .afterUpdate) else { - assertionFailure("Section with identifier \(oldSection.id) does not exist.") - return - } - let newSection = section(at: newSectionIndex, at: .afterUpdate) - compensateOffsetOfSectionIfNeeded(for: $0, - action: .frameUpdate(previousFrame: oldSection.frame, - newFrame: newSection.frame, - previousSpacing: isLastSectionInLayout($0, at: .beforeUpdate) ? 0 : oldSection.interSectionSpacing, - newSpacing: isLastSectionInLayout(newSectionIndex, at: .afterUpdate) ? 0 : newSection.interSectionSpacing), - visibleBounds: visibleBounds) - } - deletedSectionsIndexes.sorted(by: { $0 < $1 }).forEach { - let section = section(at: $0, at: .beforeUpdate) - compensateOffsetOfSectionIfNeeded(for: $0, - action: .delete(spacing: isLastSectionInLayout($0, at: .beforeUpdate) ? 0 : section.interSectionSpacing), - visibleBounds: visibleBounds) + if let itemToRestore, + let item = item(for: itemPathFor(itemToRestore.globalIndex, kind: itemToRestore.kind, state: .afterUpdate), kind: itemToRestore.kind, at: .afterUpdate), + isLayoutBiggerThanVisibleBounds(at: .afterUpdate, visibleBounds: layoutRepresentation.visibleBounds) { + let newProposedCompensationOffset = (item.frame.maxY - itemToRestore.offset) - layoutRepresentation.visibleBounds.maxY + print("RESTORE: \(itemToRestore) \(item.id) \(proposedCompensatingOffset) \(newProposedCompensationOffset)") + proposedCompensatingOffset = newProposedCompensationOffset } - - reloadedIndexes.sorted(by: { $0 < $1 }).forEach { - let newItemPath = $0.itemPath - guard let oldItem = item(for: newItemPath, kind: .cell, at: .beforeUpdate), - let newItemIndexPath = itemPath(by: oldItem.id, kind: .cell, at: .afterUpdate), - let newItem = item(for: newItemIndexPath, kind: .cell, at: .afterUpdate) else { - assertionFailure("Internal inconsistency.") - return - } - compensateOffsetIfNeeded(for: newItemPath, - kind: .cell, - action: .frameUpdate(previousFrame: oldItem.frame, - newFrame: newItem.frame, - previousSpacing: isLastItemInSection(newItemPath, at: .beforeUpdate) ? 0 : oldItem.interItemSpacing, - newSpacing: isLastItemInSection(newItemIndexPath, at: .afterUpdate) ? 0 : newItem.interItemSpacing), - visibleBounds: visibleBounds) - } - reconfiguredIndexes.sorted(by: { $0 < $1 }).forEach { - let newItemPath = $0.itemPath - guard let oldItem = item(for: newItemPath, kind: .cell, at: .beforeUpdate), - let newItemIndexPath = itemPath(by: oldItem.id, kind: .cell, at: .afterUpdate), - let newItem = item(for: newItemIndexPath, kind: .cell, at: .afterUpdate) else { - assertionFailure("Internal inconsistency.") - return - } - compensateOffsetIfNeeded(for: newItemPath, - kind: .cell, - action: .frameUpdate(previousFrame: oldItem.frame, - newFrame: newItem.frame, - previousSpacing: isLastItemInSection(newItemPath, at: .beforeUpdate) ? 0 : oldItem.interItemSpacing, - newSpacing: isLastItemInSection(newItemIndexPath, at: .afterUpdate) ? 0 : newItem.interItemSpacing), - visibleBounds: visibleBounds) - } - insertedIndexes.sorted(by: { $0 < $1 }).forEach { - let itemPath = $0.itemPath - guard let item = item(for: itemPath, kind: .cell, at: .afterUpdate) else { - assertionFailure("Internal inconsistency.") - return - } - compensateOffsetIfNeeded(for: itemPath, - kind: .cell, - action: .insert(spacing: isLastItemInSection(itemPath, at: .afterUpdate) ? 0 : item.interItemSpacing), - visibleBounds: visibleBounds) - } - deletedIndexes.sorted(by: { $0 < $1 }).forEach { - let itemPath = $0.itemPath - guard let item = item(for: itemPath, kind: .cell, at: .beforeUpdate) else { - assertionFailure("Internal inconsistency.") - return - } - compensateOffsetIfNeeded(for: itemPath, - kind: .cell, - action: .delete(spacing: isLastItemInSection(itemPath, at: .beforeUpdate) ? 0 : item.interItemSpacing), - visibleBounds: visibleBounds) - } - totalProposedCompensatingOffset = proposedCompensatingOffset } func commitUpdates() { -// print("BEFORE:") -// if layout(at: .beforeUpdate).sections.count > 0 { -// print("\(section(at: 0, at: .beforeUpdate).items.enumerated().map({ "\($0.offset): \($0.element.alignment) \($0.element.interItemSpacing) \($0.element.frame)\n" }).joined())") -// } -// print("AFTER:") -// if layout(at: .afterUpdate).sections.count > 0 { -// print("\(section(at: 0, at: .afterUpdate).items.enumerated().map({ "\($0.offset): \($0.element.alignment) \($0.element.interItemSpacing) \($0.element.frame)\n" }).joined())") -// } insertedIndexes = [] insertedSectionsIndexes = [] @@ -1078,14 +1071,18 @@ final class StateController { let itemFrame = itemFrame(for: itemPath, kind: kind, at: .afterUpdate) else { return } - if itemFrame.minY.rounded() - interItemSpacing <= minY { + if (itemFrame.minY - interItemSpacing).rounded() <= minY { + print("INSERT COMPENSATE \(itemPath.item) \(itemFrame.height) \(interItemSpacing): \(itemFrame.height + interItemSpacing)") proposedCompensatingOffset += itemFrame.height + interItemSpacing + } else { + print("INSERT NOT COMPENSATE \(itemPath.item) \(itemFrame.height) \(itemFrame.minY) \(interItemSpacing) <= \(minY)") } case let .frameUpdate(previousFrame, newFrame, oldInterItemSpacing, newInterItemSpacing): guard isLayoutBiggerThanVisibleBounds(at: .afterUpdate, withFullCompensation: true, visibleBounds: visibleBounds) else { return } if newFrame.minY.rounded() <= minY { + print("RELOAD COMPENSATE \(itemPath.item) \(newFrame.height) <-> \(previousFrame.height) : \(oldInterItemSpacing) <-> \(newInterItemSpacing): \(newFrame.height - previousFrame.height + newInterItemSpacing - oldInterItemSpacing)") batchUpdateCompensatingOffset += newFrame.height - previousFrame.height + newInterItemSpacing - oldInterItemSpacing } case let .delete(interItemSpacing): @@ -1096,11 +1093,53 @@ final class StateController { if deletedFrame.minY.rounded() <= minY { // Changing content offset for deleted items using `invalidateLayout(with:) causes UI glitches. // So we are using targetContentOffset(forProposedContentOffset:) which is going to be called after. + print("DELETE COMPENSATE \(itemPath.item) \(deletedFrame.height) \(interItemSpacing)") proposedCompensatingOffset -= (deletedFrame.height + interItemSpacing) } } } + private func isLastSectionInLayout(_ index: Int, at state: ModelState) -> Bool { + return false + let layout = layout(at: state) + guard index < layout.sections.count - 1 else { + return false + } + return false + } + + private func isLastItemInSection(_ itemPath: ItemPath, at state: ModelState) -> Bool { + return false + let layout = layout(at: state) + if itemPath.section < layout.sections.count, + itemPath.item < layout.sections[itemPath.section].items.count - 1 { + return false + } else { + return false + } + } + + func isLastItemInSection1(_ itemPath: ItemPath, at state: ModelState) -> Bool { + let layout = layout(at: state) + if itemPath.section < layout.sections.count, + itemPath.item < layout.sections[itemPath.section].items.count - 1 { + return false + } else { + return true + } + } + + private func isLastItemInSection(_ id: UUID, at state: ModelState) -> Bool { + let layout = layout(at: state) + if let itemPath = itemPath(by: id, kind: .cell, at: state), + itemPath.section < layout.sections.count, + itemPath.item < layout.sections[itemPath.section].items.count - 1 { + return false + } else { + return true + } + } + private func compensateOffsetOfSectionIfNeeded(for sectionIndex: Int, action: CompensatingAction, visibleBounds: CGRect? = nil) { diff --git a/Example/ChatLayout/Chat/Controller/DefaultChatController.swift b/Example/ChatLayout/Chat/Controller/DefaultChatController.swift index a5d47dc3..bfe60257 100644 --- a/Example/ChatLayout/Chat/Controller/DefaultChatController.swift +++ b/Example/ChatLayout/Chat/Controller/DefaultChatController.swift @@ -113,7 +113,6 @@ final class DefaultChatController: ChatController { } else { bubble = .tailed } -// let bubble: Cell.BubbleType = .tailed guard message.type != .outgoing else { lastMessageStorage = message return [.message(message, bubbleType: bubble)] @@ -305,10 +304,15 @@ extension DefaultChatController: ReloadDelegate { extension DefaultChatController: EditingAccessoryControllerDelegate { func deleteMessage(with id: UUID) { - let message = messages.first(where: { $0.id == id })! - messages = Array(messages.shuffled().filter { $0.id != id }) - messages.append(message) - +// var message = messages.first(where: { $0.id == id })! +// if case var .text(text) = message.data { +// text = String(text.prefix(max(0, text.count - 5))) +// message.data = .text(text) +// } +// messages = Array(messages.filter { $0.id != id }) +// messages.append(message) + + messages = Array(messages.filter { $0.id != id }) repopulateMessages(requiresIsolatedProcess: true) } diff --git a/Example/ChatLayout/Chat/Model/DefaultRandomDataProvider.swift b/Example/ChatLayout/Chat/Model/DefaultRandomDataProvider.swift index 4a1381a2..e3c4fead 100644 --- a/Example/ChatLayout/Chat/Model/DefaultRandomDataProvider.swift +++ b/Example/ChatLayout/Chat/Model/DefaultRandomDataProvider.swift @@ -63,7 +63,7 @@ final class DefaultRandomDataProvider: RandomDataProvider { private let enableNewMessages = false - private let enableRichContent = false + private let enableRichContent = true private let websiteUrls: [URL] = [ URL(string: "https://messagekit.github.io")!, @@ -89,7 +89,7 @@ final class DefaultRandomDataProvider: RandomDataProvider { private let images: [UIImage] = (1...8).compactMap { UIImage(named: "demo\($0)") } private var allUsersIds: [Int] { - Array([users].joined()) + Array([users /*, [receiverId] */].joined()) } init(receiverId: Int, usersIds: [Int]) { @@ -104,7 +104,7 @@ final class DefaultRandomDataProvider: RandomDataProvider { guard let self else { return } - let messages = createBunchOfMessages(number: 20) + let messages = createBunchOfMessages(number: 50) if messages.count > 10 { lastReceivedUUID = messages[messages.count - 10].id } diff --git a/Example/ChatLayout/Chat/View/ChatViewController.swift b/Example/ChatLayout/Chat/View/ChatViewController.swift index 7c0f230a..bf1af562 100644 --- a/Example/ChatLayout/Chat/View/ChatViewController.swift +++ b/Example/ChatLayout/Chat/View/ChatViewController.swift @@ -121,7 +121,7 @@ final class ChatViewController: UIViewController { navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Show Keyboard", style: .plain, target: self, action: #selector(ChatViewController.showHideKeyboard)) navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Edit", style: .plain, target: self, action: #selector(ChatViewController.setEditNotEdit)) - chatLayout.settings.interItemSpacing = 8 + chatLayout.settings.interItemSpacing = 20 chatLayout.settings.interSectionSpacing = 8 chatLayout.settings.additionalInsets = UIEdgeInsets(top: 8, left: 5, bottom: 8, right: 5) chatLayout.keepContentOffsetAtBottomOnBatchUpdates = true @@ -291,19 +291,19 @@ extension ChatViewController: UIScrollViewDelegate { } private func loadPreviousMessages() { - // // Blocking the potential multiple call of that function as during the content invalidation the contentOffset of the UICollectionView can change -// // in any way so it may trigger another call of that function and lead to unexpected behaviour/animation -// currentControllerActions.options.insert(.loadingPreviousMessages) -// chatController.loadPreviousMessages { [weak self] sections in -// guard let self else { -// return -// } -// // Reloading the content without animation just because it looks better is the scrolling is in process. -// let animated = !isUserInitiatedScrolling -// processUpdates(with: sections, animated: animated, requiresIsolatedProcess: false) { -// self.currentControllerActions.options.remove(.loadingPreviousMessages) -// } -// } + // Blocking the potential multiple call of that function as during the content invalidation the contentOffset of the UICollectionView can change + // in any way so it may trigger another call of that function and lead to unexpected behaviour/animation + currentControllerActions.options.insert(.loadingPreviousMessages) + chatController.loadPreviousMessages { [weak self] sections in + guard let self else { + return + } + // Reloading the content without animation just because it looks better is the scrolling is in process. + let animated = !isUserInitiatedScrolling + processUpdates(with: sections, animated: animated, requiresIsolatedProcess: false) { + self.currentControllerActions.options.remove(.loadingPreviousMessages) + } + } } fileprivate var isUserInitiatedScrolling: Bool { @@ -490,7 +490,7 @@ extension ChatViewController: ChatControllerDelegate { return false }, onInterruptedReload: { - let positionSnapshot = ChatLayoutPositionSnapshot(indexPath: IndexPath(item: 0, section: sections.count - 1), kind: .footer, edge: .bottom) + let positionSnapshot = ChatLayoutPositionSnapshot(indexPath: IndexPath(item: (sections.last?.cells.count ?? 0) - 1, section: sections.count - 1), kind: .cell, edge: .bottom) self.collectionView.reloadData() // We want so that user on reload appeared at the very bottom of the layout self.chatLayout.restoreContentOffset(with: positionSnapshot) @@ -506,14 +506,6 @@ extension ChatViewController: ChatControllerDelegate { } }, setData: { data in - if let section = dataSource.sections.first { - print("Before") - print("\(section.cells.enumerated().map { "\($0.offset): \(String(describing: $0.element))\n" }.joined()))") - } - if let section = data.first { - print("After") - print("\(section.cells.enumerated().map { "\($0.offset): \(String(describing: $0.element))\n" }.joined()))") - } self.dataSource.sections = data }) } diff --git a/Example/ChatLayout/Chat/View/Data Source/DefaultChatCollectionDataSource.swift b/Example/ChatLayout/Chat/View/Data Source/DefaultChatCollectionDataSource.swift index 64a1b5e7..cc213f96 100644 --- a/Example/ChatLayout/Chat/View/Data Source/DefaultChatCollectionDataSource.swift +++ b/Example/ChatLayout/Chat/View/Data Source/DefaultChatCollectionDataSource.swift @@ -359,7 +359,7 @@ extension DefaultChatCollectionDataSource: ChatLayoutDelegate { return .estimated(CGSize(width: min(85, chatLayout.layoutFrame.width / 3), height: 18)) } case .footer, .header: - return .auto + return .exact(.init(width: chatLayout.layoutFrame.width, height: 10)) } } @@ -426,23 +426,22 @@ extension DefaultChatCollectionDataSource: ChatLayoutDelegate { } public func interItemSpacing(_ chatLayout: CollectionViewChatLayout, of kind: ItemKind, after indexPath: IndexPath) -> CGFloat? { - return nil let item = sections[indexPath.section].cells[indexPath.item] -// if case let .message(_, b) = item, -// b == .tailed { -// print("Return 0 for \(indexPath.item)") -// return 0 -// } + if case let .message(_, b) = item, + b == .tailed { + return 100 + } switch item { case .messageGroup: - return 3 + return 0 + case .date: + return 50 default: - print("Return 50 for \(indexPath.item)") - return 150 + return 0 } } public func interSectionSpacing(_ chatLayout: CollectionViewChatLayout, after sectionIndex: Int) -> CGFloat? { - 50 + 0 } }