diff --git a/Chatto/sources/ChatController/ChatMessages/New/CollectionUpdateProvider.swift b/Chatto/sources/ChatController/ChatMessages/New/CollectionUpdateProvider.swift index f01c1659..991d8a4d 100644 --- a/Chatto/sources/ChatController/ChatMessages/New/CollectionUpdateProvider.swift +++ b/Chatto/sources/ChatController/ChatMessages/New/CollectionUpdateProvider.swift @@ -26,8 +26,13 @@ import UIKit // TODO: Rename public final class CollectionUpdateProvider: CollectionUpdateProviderProtocol { - // TODO: Stop sharing configuration with adapter - public typealias Configuration = NewChatMessageCollectionAdapter.Configuration + public struct Configuration { + let isRegisteringPresentersAutomatically: Bool + + public init(isRegisteringPresentersAutomatically: Bool) { + self.isRegisteringPresentersAutomatically = isRegisteringPresentersAutomatically + } + } // MARK: - Private properties diff --git a/Chatto/sources/ChatController/ChatMessages/New/NewChatMessageCollectionAdapter.swift b/Chatto/sources/ChatController/ChatMessages/New/NewChatMessageCollectionAdapter.swift index 4af19e54..8dbc681a 100644 --- a/Chatto/sources/ChatController/ChatMessages/New/NewChatMessageCollectionAdapter.swift +++ b/Chatto/sources/ChatController/ChatMessages/New/NewChatMessageCollectionAdapter.swift @@ -23,6 +23,7 @@ import UIKit +@available(iOS 13, *) public final class NewChatMessageCollectionAdapter: NSObject, ChatMessageCollectionAdapterProtocol, ChatDataSourceDelegateProtocol, @@ -31,10 +32,21 @@ public final class NewChatMessageCollectionAdapter: NSObject, // MARK: - Private type declarations private struct ModelUpdates { + struct ScrollPositionData { + let oldRect: CGRect + let referenceIndexPath: IndexPath + } + let updateType: UpdateType let changes: CollectionChanges let collection: ChatItemCompanionCollection let layoutModel: ChatCollectionViewLayoutModel + + var scrollPositionData: ScrollPositionData? = nil + } + + private enum Error: Swift.Error { + case internalInconsistancy } // MARK: - Private properties @@ -42,7 +54,7 @@ public final class NewChatMessageCollectionAdapter: NSObject, private let configuration: Configuration private let collectionUpdateProvider: CollectionUpdateProviderProtocol private let layoutFactory: ChatCollectionViewLayoutModelFactoryProtocol - private let collectionUpdatesQueue: SerialTaskQueueProtocol + private let collectionUpdatesQueue: NewSerialTaskQueueProtocol private let referenceIndexPathRestoreProvider: ReferenceIndexPathRestoreProvider // MARK: - State @@ -57,7 +69,7 @@ public final class NewChatMessageCollectionAdapter: NSObject, init(configuration: Configuration, collectionUpdateProvider: CollectionUpdateProviderProtocol, - collectionUpdatesQueue: SerialTaskQueueProtocol, + collectionUpdatesQueue: NewSerialTaskQueueProtocol, layoutFactory: ChatCollectionViewLayoutModelFactoryProtocol, referenceIndexPathRestoreProvider: @escaping ReferenceIndexPathRestoreProvider) { self.configuration = configuration @@ -75,11 +87,11 @@ public final class NewChatMessageCollectionAdapter: NSObject, public var delegate: ChatMessageCollectionAdapterDelegate? public func startProcessingUpdates() { - self.collectionUpdatesQueue.start() + Task { await self.collectionUpdatesQueue.start() } } public func stopProcessingUpdates() { - self.collectionUpdatesQueue.stop() + Task { await self.collectionUpdatesQueue.stop() } } public func setup(in collectionView: UICollectionView) { @@ -134,9 +146,6 @@ public final class NewChatMessageCollectionAdapter: NSObject, oldPresenterForCell.cellWasHidden(cell) } - // TODO: Remove from configuration - guard self.configuration.fastUpdates else { return } - if let visibleCell = self.visibleCells[indexPath], visibleCell === cell { self.visibleCells[indexPath] = nil } else { @@ -157,10 +166,7 @@ public final class NewChatMessageCollectionAdapter: NSObject, let presenter = self.presenter(for: indexPath) self.presentersByCell.setObject(presenter, forKey: cell) - // TODO: Remove from configuration - if self.configuration.fastUpdates { - self.visibleCells[indexPath] = cell - } + self.visibleCells[indexPath] = cell let shouldAnimate = self.delegate?.chatMessageCollectionAdapterShouldAnimateCellOnDisplay() ?? false if !shouldAnimate { @@ -234,24 +240,33 @@ public final class NewChatMessageCollectionAdapter: NSObject, collectionViewWidth: collectionView.bounds.width ) self.layoutModel = layoutModel + } + + private func enqueueModelUpdate(type: UpdateType, completion: (@MainActor () -> Void)? = nil) { + var updateType = type + self.fixUpdateTypeIfNeeded(updateType: &updateType) + Task.detached { [weak self, updateType] in + guard let self = self else { return } + await self.asyncEnqueueModelUpdate(type: updateType) + await completion?() + } } - private func enqueueModelUpdate(type: UpdateType, completion: (() -> Void)? = nil) { - let task: TaskClosure = { [weak self] runNextTask in - self?.updateModels(updateType: type) { [weak self] updates in - self?.performBatchUpdates(updates: updates) { [weak self] in - guard let self = self else { return } - self.delegate?.chatMessageCollectionAdapterDidUpdateItems(withUpdateType: type) - completion?() - DispatchQueue.main.async { - // Reduces inconsistencies before next update: https://github.com/diegosanchezr/UICollectionViewStressing - runNextTask() - } - } - } + @MainActor + private func asyncEnqueueModelUpdate(type: UpdateType) async { + await self.collectionUpdatesQueue.enqueue { [weak self] in + guard let self = self else { return } + do { + let updates = try await self.updatedModels(updateType: type) + try await self.reloadView(with: updates) + self.notifyDelegateAboutUpdate(with: type) + } catch { } } - self.collectionUpdatesQueue.addTask(task) + } + + private func notifyDelegateAboutUpdate(with type: UpdateType) { + self.delegate?.chatMessageCollectionAdapterDidUpdateItems(withUpdateType: type) } private func presenter(for indexPath: IndexPath) -> ChatItemPresenterProtocol { @@ -261,115 +276,95 @@ public final class NewChatMessageCollectionAdapter: NSObject, return self.chatItemCompanionCollection[index].presenter } - private func performBatchUpdates(updates: ModelUpdates, completion: @escaping () -> Void) { - guard let collectionView = self.collectionView else { return } + @MainActor + private func reloadView(with updates: ModelUpdates) async throws { + if self.shouldReloadInstantly(for: updates) { + self.reloadInstantly(with: updates) + } else { + try await self.reloadWithAnimation(with: updates) + } + if let scrollPositionData = updates.scrollPositionData { + self.adjustScroll(with: scrollPositionData) + } + } + private func shouldReloadInstantly(for updates: ModelUpdates) -> Bool { let updateType = updates.updateType - let collection = updates.collection - let changes = updates.changes - let layout = updates.layoutModel - - let updateModelClosure = { [weak self] in - guard let self = self else { return } - self.chatItemCompanionCollection = collection - self.layoutModel = layout + let visibleCellsAreValid = self.visibleCellsAreValid(changes: updates.changes) + let wantsReloadData: Bool + switch updateType { + case .normal, .firstSync: + wantsReloadData = false + case .firstLoad, .pagination, .reload, .messageCountReduction: + wantsReloadData = true } + return wantsReloadData || !visibleCellsAreValid + } - let usesBatchUpdates: Bool = { - let visibleCellsAreValid = self.visibleCellsAreValid(changes: changes) - let wantsReloadData = updateType != .normal && updateType != .firstSync - return !wantsReloadData && visibleCellsAreValid - }() - - let scrollAction: ScrollAction - do { // Scroll action - if updateType != .pagination && updateType != .firstSync && collectionView.isScrolledAtBottom() { - scrollAction = .scrollToBottom - } else { - let (oldReferenceIndexPath, newReferenceIndexPath) = self.referenceIndexPathRestoreProvider(collection, changes) - let oldRect = self.rectAtIndexPath(oldReferenceIndexPath) - scrollAction = .preservePosition( - rectForReferenceIndexPathBeforeUpdate: oldRect, - referenceIndexPathAfterUpdate: newReferenceIndexPath - ) - } - } + private func reloadInstantly(with updates: ModelUpdates) { + guard let collectionView = self.collectionView else { return } - let myCompletion: () -> Void - do { // Completion - var myCompletionExecuted = false - myCompletion = { - if myCompletionExecuted { return } - myCompletionExecuted = true - completion() - } - } + self.visibleCells = [:] + self.applyModelChange(from: updates) + collectionView.reloadData() + collectionView.collectionViewLayout.prepare() - let adjustScrollViewToBottom = { [weak self, weak collectionView] in - guard let self = self, let collectionView = collectionView else { return } - - switch scrollAction { - case .scrollToBottom: - collectionView.scrollToBottom( - animated: updateType == .normal, - animationDuration: self.configuration.updatesAnimationDuration - ) - case .preservePosition(let oldRect, let indexPath): - let newRect = self.rectAtIndexPath(indexPath) - collectionView.scrollToPreservePosition(oldRefRect: oldRect, newRefRect: newRect) - } - } + collectionView.setNeedsLayout() + collectionView.layoutIfNeeded() + } - if usesBatchUpdates { - UIView.animate( - withDuration: self.configuration.updatesAnimationDuration, - animations: { [weak self] () -> Void in - guard let self = self else { return } + @MainActor + private func reloadWithAnimation(with updates: ModelUpdates) async throws { + guard let collectionView = self.collectionView else { + throw Error.internalInconsistancy + } - collectionView.performBatchUpdates({ [weak self] in - guard let self = self else { return } + let changes = updates.changes - updateModelClosure() - self.updateVisibleCells(changes) // For instance, to support removal of tails + await withCheckedContinuation { [weak self] (continuation: CheckedContinuation) -> Void in + guard let self = self else { return } + self.animate { [weak self] in + collectionView.performBatchUpdates { [weak self] in + guard let self = self else { return } - collectionView.deleteItems(at: Array(changes.deletedIndexPaths)) - collectionView.insertItems(at: Array(changes.insertedIndexPaths)) + self.applyModelChange(from: updates) + self.updateVisibleCells(changes) // For instance, to support removal of tails - for move in changes.movedIndexPaths { - collectionView.moveItem(at: move.indexPathOld, to: move.indexPathNew) - } - }, completion: { _ in - myCompletion() - }) - } - ) - } else { - self.visibleCells = [:] - updateModelClosure() - collectionView.reloadData() - collectionView.collectionViewLayout.prepare() + collectionView.deleteItems(at: Array(changes.deletedIndexPaths)) + collectionView.insertItems(at: Array(changes.insertedIndexPaths)) - collectionView.setNeedsLayout() - collectionView.layoutIfNeeded() + for move in changes.movedIndexPaths { + collectionView.moveItem(at: move.indexPathOld, to: move.indexPathNew) + } + } completion: { _ in continuation.resume() } + } } + } + + private func adjustScroll(with preservation: ModelUpdates.ScrollPositionData) { + guard let collectionView = self.collectionView else { return } + let newRect = self.rectAtIndexPath(preservation.referenceIndexPath) + collectionView.scrollToPreservePosition(oldRefRect: preservation.oldRect, newRefRect: newRect) + } - adjustScrollViewToBottom() + private func applyModelChange(from updates: ModelUpdates) { + self.chatItemCompanionCollection = updates.collection + self.layoutModel = updates.layoutModel + } - if !usesBatchUpdates || self.configuration.fastUpdates { - myCompletion() - } + private func animate(animations: @escaping () -> Void) { + UIView.animate(withDuration: self.configuration.updatesAnimationDuration) { animations() } } - private func createModelUpdates(updateType: UpdateType) -> ModelUpdates { - // TODO: Fix fatalError - guard let collectionView = self.collectionView else { fatalError() } - let old = self.chatItemCompanionCollection + private func createModelUpdates(updateType: UpdateType, + old: ChatItemCompanionCollection, + maxWidth: CGFloat) -> ModelUpdates { let new = self.collectionUpdateProvider.updateCollection(old: old) let changes = generateChanges(oldCollection: old.map(HashableItem.init), newCollection: new.map(HashableItem.init)) let layoutModel = self.layoutFactory.createLayoutModel( items: new, - collectionViewWidth: collectionView.bounds.width + collectionViewWidth: maxWidth ) return ModelUpdates( updateType: updateType, @@ -379,21 +374,44 @@ public final class NewChatMessageCollectionAdapter: NSObject, ) } - private func updateModels(updateType: UpdateType, - completion: @escaping (ModelUpdates) -> Void) { + @MainActor + private func updatedModels(updateType: UpdateType) async throws -> ModelUpdates { + guard let collectionView = self.collectionView else { + throw Error.internalInconsistancy + } + let performInBackground = updateType != .firstLoad + let maxWidth = collectionView.bounds.width + let old = self.chatItemCompanionCollection + let createModelUpdates: () throws -> ModelUpdates = { [weak self] in + guard let self = self else { throw Error.internalInconsistancy } + return self.createModelUpdates(updateType: updateType, + old: old, + maxWidth: maxWidth) + } + + var modelUpdates: ModelUpdates if performInBackground { - DispatchQueue.global(qos: .userInitiated).async { - let updates = self.createModelUpdates(updateType: updateType) - DispatchQueue.main.async { - completion(updates) - } - } + modelUpdates = try await Task.detached { try createModelUpdates() }.value } else { - let updates = self.createModelUpdates(updateType: updateType) - completion(updates) + modelUpdates = try createModelUpdates() } + + self.setupScrollPositionData(in: &modelUpdates) + + return modelUpdates + } + + private func fixUpdateTypeIfNeeded(updateType: inout UpdateType) { + guard self.delegate?.isFirstLoad == true else { return } + updateType = .firstLoad + } + + private func setupScrollPositionData(in updates: inout ModelUpdates) { + guard case let (old, new?) = self.referenceIndexPathRestoreProvider(updates.collection, updates.changes), + let oldRect = self.rectAtIndexPath(old) else { return } + updates.scrollPositionData = .init(oldRect: oldRect, referenceIndexPath: new) } private func rectAtIndexPath(_ indexPath: IndexPath?) -> CGRect? { @@ -404,12 +422,8 @@ public final class NewChatMessageCollectionAdapter: NSObject, } private func visibleCellsAreValid(changes: CollectionChanges) -> Bool { - guard self.configuration.fastUpdates else { - return true - } - // After performBatchUpdates, indexPathForCell may return a cell refering to the state before the update - // if self.updatesConfig.fastUpdates is enabled, very fast updates could result in `updateVisibleCells` updating wrong cells. + // Very fast updates could result in `updateVisibleCells` updating wrong cells. // See more: https://github.com/diegosanchezr/UICollectionViewStressing let updatesFromVisibleCells = updated(collection: self.visibleCells, withChanges: changes) let updatesFromCollectionViewApi = updated(collection: self.visibleCellsFromCollectionViewApi(), withChanges: changes) @@ -446,9 +460,10 @@ public final class NewChatMessageCollectionAdapter: NSObject, } // TODO: Remove later +@available(iOS 13, *) public extension NewChatMessageCollectionAdapter { static func make(configuration: Configuration, - updateQueue: SerialTaskQueueProtocol, + updateQueue: NewSerialTaskQueueProtocol, chatItemPresenterFactory: ChatItemPresenterFactoryProtocol, chatItemsDecorator: ChatItemsDecoratorProtocol, referenceIndexPathRestoreProvider: @escaping ReferenceIndexPathRestoreProvider, @@ -456,7 +471,7 @@ public extension NewChatMessageCollectionAdapter { let configuration: NewChatMessageCollectionAdapter.Configuration = .default let collectionUpdateProvider = CollectionUpdateProvider( - configuration: configuration, + configuration: .init(adapterConfiguration: configuration), chatItemsDecorator: chatItemsDecorator, chatItemPresenterFactory: chatItemPresenterFactory, chatMessagesViewModel: chatMessagesViewModel @@ -502,4 +517,3 @@ private struct HashableItem: Hashable { self.type = chatItemCompanion.chatItem.type } } - diff --git a/Chatto/sources/ChatController/ChatMessages/New/NewChatMessageCollectionAdapterConfiguration.swift b/Chatto/sources/ChatController/ChatMessages/New/NewChatMessageCollectionAdapterConfiguration.swift index 490897bd..8c7089b7 100644 --- a/Chatto/sources/ChatController/ChatMessages/New/NewChatMessageCollectionAdapterConfiguration.swift +++ b/Chatto/sources/ChatController/ChatMessages/New/NewChatMessageCollectionAdapterConfiguration.swift @@ -23,11 +23,11 @@ import UIKit +@available(iOS 13, *) public extension NewChatMessageCollectionAdapter { struct Configuration { public var autoloadingFractionalThreshold: CGFloat public var coalesceUpdates: Bool - public var fastUpdates: Bool public var isRegisteringPresentersAutomatically: Bool public var preferredMaxMessageCount: Int? public var preferredMaxMessageCountAdjustment: Int @@ -35,14 +35,12 @@ public extension NewChatMessageCollectionAdapter { public init(autoloadingFractionalThreshold: CGFloat, coalesceUpdates: Bool, - fastUpdates: Bool, isRegisteringPresentersAutomatically: Bool, preferredMaxMessageCount: Int?, preferredMaxMessageCountAdjustment: Int, updatesAnimationDuration: TimeInterval) { self.autoloadingFractionalThreshold = autoloadingFractionalThreshold self.coalesceUpdates = coalesceUpdates - self.fastUpdates = fastUpdates self.isRegisteringPresentersAutomatically = isRegisteringPresentersAutomatically self.preferredMaxMessageCount = preferredMaxMessageCount self.preferredMaxMessageCountAdjustment = preferredMaxMessageCountAdjustment @@ -51,12 +49,12 @@ public extension NewChatMessageCollectionAdapter { } } +@available(iOS 13, *) public extension NewChatMessageCollectionAdapter.Configuration { static var `default`: Self { return .init( autoloadingFractionalThreshold: 0.05, coalesceUpdates: true, - fastUpdates: true, isRegisteringPresentersAutomatically: true, preferredMaxMessageCount: 500, preferredMaxMessageCountAdjustment: 400, @@ -65,3 +63,10 @@ public extension NewChatMessageCollectionAdapter.Configuration { } } +@available(iOS 13, *) +public extension CollectionUpdateProvider.Configuration { + public init(adapterConfiguration configuration: NewChatMessageCollectionAdapter.Configuration) { + self.init(isRegisteringPresentersAutomatically: configuration.isRegisteringPresentersAutomatically) + } +} + diff --git a/Chatto/sources/SerialTaskQueue.swift b/Chatto/sources/SerialTaskQueue.swift index d9f728c9..5014f575 100644 --- a/Chatto/sources/SerialTaskQueue.swift +++ b/Chatto/sources/SerialTaskQueue.swift @@ -78,3 +78,47 @@ public final class SerialTaskQueue: SerialTaskQueueProtocol { } } } + +@available(iOS 13, *) +public protocol NewSerialTaskQueueProtocol { + typealias AsyncTaskClosure = @MainActor () async -> Void + func start() async + func stop() async + func enqueue(task: @escaping AsyncTaskClosure) async +} + +@available(iOS 13, *) +public actor NewSerialTaskQueue: NewSerialTaskQueueProtocol { + + // MARK: - State + + private var stopped = true + private var tasks: [AsyncTaskClosure] = [] + + // MARK: - Instantiation + + // MARK: - NewSerialTaskQueueProtocol + + public func start() async { + self.stopped = false + await run() + } + + public func stop() async { + self.stopped = true + } + + public func enqueue(task: @escaping AsyncTaskClosure) async { + self.tasks.append(task) + await self.run() + } + + // MARK: - Private + + private func run() async { + guard !self.stopped && !self.tasks.isEmpty else { return } + let task = self.tasks.removeFirst() + await task() + await self.run() + } +}