// // Copyright (c) 2021 Open Whisper Systems. All rights reserved. // import Foundation public enum ScrollContinuity: CustomStringConvertible { // Do not try to maintain scroll continuity. case none // Try to maintain scroll continuity by invalidating // the layout with a contentOffsetAdjustment. // // If isRelativeToTop is true, the top-most visible interaction // in the chat history should remain the same distance from the // top of the chat history (assuming content didn't change, // interactions didn't expire, etc.). // // If isRelativeToTop is false, the bottom-most visible interaction // in the chat history above the keyboard should remain the same // distance from the top of the keyboard (again, everything else // being equal). case contentRelativeToViewport(token: CVScrollContinuityToken, isRelativeToTop: Bool) // Try to maintain scroll continuity using the delegate method: // // CVC.targetContentOffset(forProposedContentOffset(). // // This delegate method handles cases like view size transitions, // orientation changes, message actions, etc. case delegateScrollContinuity // MARK: - CustomStringConvertible public var description: String { switch self { case .none: return "none" case .contentRelativeToViewport(_, let isRelativeToTop): return "contentRelativeToViewport(isRelativeToTop: \(isRelativeToTop))" case .delegateScrollContinuity: return "delegateScrollContinuity" } } } // MARK: - @objc public protocol ConversationViewLayoutItem { var interactionUniqueId: String { get } var cellSize: CGSize { get } func vSpacing(previousLayoutItem: ConversationViewLayoutItem) -> CGFloat } // MARK: - public protocol ConversationViewLayoutDelegate: AnyObject { var layoutItems: [ConversationViewLayoutItem] { get } var renderStateId: UInt { get } var layoutHeaderHeight: CGFloat { get } var layoutFooterHeight: CGFloat { get } var conversationViewController: ConversationViewController? { get } } // MARK: - public class ConversationViewLayout: UICollectionViewLayout { public weak var delegate: ConversationViewLayoutDelegate? private var conversationStyle: ConversationStyle fileprivate struct ItemLayout { let interactionUniqueId: String let indexPath: IndexPath let layoutAttributes: UICollectionViewLayoutAttributes } fileprivate class LayoutInfo { let viewWidth: CGFloat let contentSize: CGSize let itemAttributesMap: [Int: UICollectionViewLayoutAttributes] let headerLayoutAttributes: UICollectionViewLayoutAttributes? let footerLayoutAttributes: UICollectionViewLayoutAttributes? let itemLayouts: [ItemLayout] let renderStateId: UInt required init(viewWidth: CGFloat, contentSize: CGSize, itemAttributesMap: [Int: UICollectionViewLayoutAttributes], headerLayoutAttributes: UICollectionViewLayoutAttributes?, footerLayoutAttributes: UICollectionViewLayoutAttributes?, itemLayouts: [ItemLayout], renderStateId: UInt) { self.viewWidth = viewWidth self.contentSize = contentSize self.itemAttributesMap = itemAttributesMap self.headerLayoutAttributes = headerLayoutAttributes self.footerLayoutAttributes = footerLayoutAttributes self.itemLayouts = itemLayouts self.renderStateId = renderStateId } func layoutAttributesForItem(at indexPath: IndexPath, assertIfMissing: Bool) -> UICollectionViewLayoutAttributes? { if assertIfMissing { owsAssertDebug(indexPath.row >= 0 && indexPath.row < itemAttributesMap.count) } return itemAttributesMap[indexPath.row] } func layoutAttributesForSupplementaryElement(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { if elementKind == UICollectionView.elementKindSectionHeader, let headerLayoutAttributes = headerLayoutAttributes, headerLayoutAttributes.indexPath == indexPath { return headerLayoutAttributes } if elementKind == UICollectionView.elementKindSectionFooter, let footerLayoutAttributes = footerLayoutAttributes, footerLayoutAttributes.indexPath == indexPath { return footerLayoutAttributes } return nil } var debugDescription: String { var result = "[" for item in itemAttributesMap.keys.sorted() { guard let itemAttributes = itemAttributesMap[item] else { owsFailDebug("Missing attributes for item: \(item)") continue } result += "item: \(itemAttributes.indexPath), " } if let headerLayoutAttributes = headerLayoutAttributes { result += "header: \(headerLayoutAttributes.indexPath), " } if let footerLayoutAttributes = footerLayoutAttributes { result += "footer: \(footerLayoutAttributes.indexPath), " } result += "]" return result } } private var hasEverHadLayout = false private var currentLayoutInfo: LayoutInfo? private func ensureCurrentLayoutInfo() -> LayoutInfo { AssertIsOnMainThread() if let layoutInfo = currentLayoutInfo { return layoutInfo } ensureState() let layoutInfo = Self.buildLayoutInfo(state: currentState) currentLayoutInfo = layoutInfo hasEverHadLayout = true return layoutInfo } public required init(conversationStyle: ConversationStyle) { self.conversationStyle = conversationStyle super.init() } @available(*, unavailable, message: "Use other constructor instead.") public required init(coder aDecoder: NSCoder) { notImplemented() } public func update(conversationStyle: ConversationStyle) { AssertIsOnMainThread() guard !self.conversationStyle.isEqualForCellRendering(conversationStyle) else { return } self.conversationStyle = conversationStyle invalidateLayout() } public override func invalidateLayout() { AssertIsOnMainThread() super.invalidateLayout() // This method will call invalidateLayout(with:). // We don't want to assume that, so we call ensureState() to be safe. ensureState() } public override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) { AssertIsOnMainThread() ensureState() super.invalidateLayout(with: context) } private func ensureState() { AssertIsOnMainThread() let newState = State.build(delegate: delegate, conversationStyle: conversationStyle) guard newState != currentState else { return } currentState = newState currentLayoutInfo = nil } public override func prepare() { super.prepare() _ = ensureCurrentLayoutInfo() } private var currentState: State? private struct State: Equatable { let conversationStyle: ConversationStyle let renderStateId: UInt let layoutItems: [ConversationViewLayoutItem] let layoutHeaderHeight: CGFloat let layoutFooterHeight: CGFloat static func build(delegate: ConversationViewLayoutDelegate?, conversationStyle: ConversationStyle) -> State? { guard let delegate = delegate else { return nil } return State(conversationStyle: conversationStyle, renderStateId: delegate.renderStateId, layoutItems: delegate.layoutItems, layoutHeaderHeight: delegate.layoutHeaderHeight, layoutFooterHeight: delegate.layoutFooterHeight) } // MARK: Equatable static func == (lhs: State, rhs: State) -> Bool { // Comparing the layoutItems is expensive. We can avoid that by // comparing renderStateIds. (lhs.conversationStyle.isEqualForCellRendering(rhs.conversationStyle) && lhs.renderStateId == rhs.renderStateId && lhs.layoutHeaderHeight == rhs.layoutHeaderHeight && lhs.layoutFooterHeight == rhs.layoutFooterHeight) } } private static func buildLayoutInfo(state: State?) -> LayoutInfo { AssertIsOnMainThread() func buildEmptyLayoutInfo() -> LayoutInfo { return LayoutInfo(viewWidth: 0, contentSize: .zero, itemAttributesMap: [:], headerLayoutAttributes: nil, footerLayoutAttributes: nil, itemLayouts: [], renderStateId: 0) } guard let state = state else { owsFailDebug("Missing state") return buildEmptyLayoutInfo() } let conversationStyle = state.conversationStyle let layoutItems = state.layoutItems let layoutHeaderHeight = state.layoutHeaderHeight let layoutFooterHeight = state.layoutFooterHeight let viewWidth: CGFloat = conversationStyle.viewWidth guard viewWidth > 0 else { return buildEmptyLayoutInfo() } var y: CGFloat = 0 var itemAttributesMap = [Int: UICollectionViewLayoutAttributes]() var headerLayoutAttributes: UICollectionViewLayoutAttributes? var footerLayoutAttributes: UICollectionViewLayoutAttributes? if layoutItems.isEmpty || layoutHeaderHeight <= 0 { // Do nothing. } else { let headerIndexPath = IndexPath(row: 0, section: 0) let layoutAttributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, with: headerIndexPath) layoutAttributes.frame = CGRect(x: 0, y: y, width: viewWidth, height: layoutHeaderHeight) headerLayoutAttributes = layoutAttributes y += layoutHeaderHeight } y += conversationStyle.contentMarginTop var contentBottom: CGFloat = y var row: Int = 0 var previousLayoutItem: ConversationViewLayoutItem? var itemLayouts = [ItemLayout]() for layoutItem in layoutItems { if let previousLayoutItem = previousLayoutItem { y += layoutItem.vSpacing(previousLayoutItem: previousLayoutItem) } var layoutSize = layoutItem.cellSize.ceil // Ensure cell fits within view. if layoutSize.width > viewWidth { // This can happen due to safe area insets, orientation changes, etc. Logger.warn("Oversize cell layout: \(layoutSize.width) <= viewWidth: \(viewWidth)") } layoutSize.width = min(viewWidth, layoutSize.width) // All cells are "full width" and are responsible for aligning their own content. let itemFrame = CGRect(x: 0, y: y, width: viewWidth, height: layoutSize.height) let indexPath = IndexPath(row: row, section: 0) let itemAttributes = UICollectionViewLayoutAttributes(forCellWith: indexPath) itemAttributes.frame = itemFrame itemAttributesMap[row] = itemAttributes contentBottom = itemFrame.origin.y + itemFrame.size.height y = contentBottom row += 1 previousLayoutItem = layoutItem itemLayouts.append(ItemLayout(interactionUniqueId: layoutItem.interactionUniqueId, indexPath: indexPath, layoutAttributes: itemAttributes)) } contentBottom += conversationStyle.contentMarginBottom if row > 0 { let footerIndexPath = IndexPath(row: row - 1, section: 0) if layoutItems.isEmpty || layoutFooterHeight <= 0 || headerLayoutAttributes?.indexPath == footerIndexPath { // Do nothing. } else { let layoutAttributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, with: footerIndexPath) layoutAttributes.frame = CGRect(x: 0, y: contentBottom, width: viewWidth, height: layoutFooterHeight) footerLayoutAttributes = layoutAttributes contentBottom += layoutFooterHeight } } let contentSize = CGSize(width: viewWidth, height: contentBottom) let renderStateId = state.renderStateId return LayoutInfo(viewWidth: viewWidth, contentSize: contentSize, itemAttributesMap: itemAttributesMap, headerLayoutAttributes: headerLayoutAttributes, footerLayoutAttributes: footerLayoutAttributes, itemLayouts: itemLayouts, renderStateId: renderStateId) } // MARK: - UICollectionViewLayout Impl. public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { AssertIsOnMainThread() let layoutInfo = ensureCurrentLayoutInfo() var result = [UICollectionViewLayoutAttributes]() if let headerLayoutAttributes = layoutInfo.headerLayoutAttributes { result.append(headerLayoutAttributes) } for itemLayout in layoutInfo.itemLayouts { result.append(itemLayout.layoutAttributes) } if let footerLayoutAttributes = layoutInfo.footerLayoutAttributes { result.append(footerLayoutAttributes) } return result.filter { $0.frame.intersects(rect) } } public override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { AssertIsOnMainThread() let layoutInfo = ensureCurrentLayoutInfo() return layoutInfo.layoutAttributesForItem(at: indexPath, assertIfMissing: true) } public override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { AssertIsOnMainThread() let layoutInfo = ensureCurrentLayoutInfo() return layoutInfo.layoutAttributesForSupplementaryElement(ofKind: elementKind, at: indexPath) } public override var collectionViewContentSize: CGSize { AssertIsOnMainThread() return ensureCurrentLayoutInfo().contentSize } public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true } // MARK: - performBatchUpdates() & reloadData() // Flag set before reloadData() and cleared after it _completes_. private var isReloadingData = false // Flag set before performBatchUpdates() and cleared after it _returns_. private var isPerformingBatchUpdates = false private enum DelegateScrollContinuityMode: Equatable { case disabled case enabled(lastKnownDistanceFromBottom: CGFloat?) case enabledIOS12(token: CVScrollContinuityToken, isRelativeToTop: Bool, lastKnownDistanceFromBottom: CGFloat?) } private var delegateScrollContinuityMode: DelegateScrollContinuityMode = .disabled // Returns true during performBatchUpdates() or reloadData(). // Unlike isPerformBatchUpdatesOrReloadDataBeingAppliedOrSettling, this // returns true after performBatchUpdates() returns, before // its completion is called. public var isPerformBatchUpdatesOrReloadDataBeingApplied: Bool { isPerformingBatchUpdates || isReloadingData } private let updateCompletionCounter = AtomicUInt(0) // Returns true during performBatchUpdates() or reloadData(). // Unlike isPerformBatchUpdatesOrReloadDataBeingApplied, this // returns true until the completion of performBatchUpdates(). public var isPerformBatchUpdatesOrReloadDataBeingAppliedOrSettling: Bool { updateCompletionCounter.get() > 0 } public func willPerformBatchUpdates(scrollContinuity: ScrollContinuity, lastKnownDistanceFromBottom: CGFloat?) { AssertIsOnMainThread() owsAssertDebug(!isReloadingData) owsAssertDebug(!isPerformingBatchUpdates) owsAssertDebug(delegateScrollContinuityMode == .disabled) isPerformingBatchUpdates = true updateCompletionCounter.increment() delegateScrollContinuityMode = .disabled switch scrollContinuity { case .none: break case .contentRelativeToViewport(let token, let isRelativeToTop): if #available(iOS 13, *) { if !applyContentOffsetAdjustmentIfNecessary(scrollContinuityToken: token, isRelativeToTop: isRelativeToTop) { delegateScrollContinuityMode = .enabled(lastKnownDistanceFromBottom: lastKnownDistanceFromBottom) } } else { // On iOS 12, we can't safely invalidate the context before performBatchUpdates() // begins, so we use a special .delegateScrollContinuity mode. delegateScrollContinuityMode = .enabledIOS12(token: token, isRelativeToTop: isRelativeToTop, lastKnownDistanceFromBottom: lastKnownDistanceFromBottom) } case .delegateScrollContinuity: delegateScrollContinuityMode = .enabled(lastKnownDistanceFromBottom: lastKnownDistanceFromBottom) } } private func applyContentOffsetAdjustmentIfNecessary(scrollContinuityToken: CVScrollContinuityToken, isRelativeToTop: Bool) -> Bool { // When landing some CVC loads, we maintain scroll continuity by setting a // `contentOffsetAdjustment` on the UICollectionViewLayoutInvalidationContext // pass to invalidateLayout(). The timing of this adjustment to the // `contentOffset` is delicate. It must be done just before // UICollectionView.performBatchUpdates(). ensureState() let layoutInfoAfterUpdate = ensureCurrentLayoutInfo() // TODO: Capture a CVScrollContinuityToken before view transition, // orientation changes, etc. guard let contentOffsetAdjustment = Self.invalidationContentOffsetAdjustment(scrollContinuityToken: scrollContinuityToken, layoutInfoAfterUpdate: layoutInfoAfterUpdate, isRelativeToTop: isRelativeToTop) else { return false } guard contentOffsetAdjustment != .zero else { // If no adjustment is necessary, consider that success but // do not bother calling invalidateLayout(). return true } let context = UICollectionViewLayoutInvalidationContext() context.contentOffsetAdjustment = contentOffsetAdjustment self.invalidateLayout(with: context) return true } // Try to determine the correct adjustment to `content offset` that will // ensure scroll continuity. private static func invalidationContentOffsetAdjustment(scrollContinuityToken: CVScrollContinuityToken, layoutInfoAfterUpdate: LayoutInfo, isRelativeToTop: Bool) -> CGPoint? { let layoutInfoBeforeUpdate = scrollContinuityToken.layoutInfo func buildItemLayoutMap(layoutInfo: LayoutInfo) -> [String: ItemLayout] { var result = [String: ItemLayout]() for itemLayout in layoutInfo.itemLayouts { result[itemLayout.interactionUniqueId] = itemLayout } return result } let beforeItemLayoutMap = buildItemLayoutMap(layoutInfo: layoutInfoBeforeUpdate) let afterItemLayoutMap = buildItemLayoutMap(layoutInfo: layoutInfoAfterUpdate) func calculateAdjustment(beforeItemLayout: ItemLayout, afterItemLayout: ItemLayout) -> CGPoint { let frameBeforeUpdate = beforeItemLayout.layoutAttributes.frame let frameAfterUpdate = afterItemLayout.layoutAttributes.frame let offset = frameAfterUpdate.origin - frameBeforeUpdate.origin let contentOffsetAdjustment = CGPoint(x: 0, y: offset.y) return contentOffsetAdjustment } // Prefer to maintain continuity with visible interactions. // // Honor the scroll continuity bias. If we prefer continuity with regard // to the bottom of the viewport, start with the last items. let visibleUniqueIds = (isRelativeToTop ? scrollContinuityToken.visibleUniqueIds : scrollContinuityToken.visibleUniqueIds.reversed()) for visibleUniqueId in visibleUniqueIds { guard let beforeItemLayout = beforeItemLayoutMap[visibleUniqueId], let afterItemLayout = afterItemLayoutMap[visibleUniqueId] else { continue } return calculateAdjustment(beforeItemLayout: beforeItemLayout, afterItemLayout: afterItemLayout) } // Fail over to trying to use any interaction in the before & after // load windows. Again, honor the scroll continuity bias. let afterItemLayouts = (isRelativeToTop ? layoutInfoAfterUpdate.itemLayouts : layoutInfoAfterUpdate.itemLayouts.reversed()) for afterItemLayout in afterItemLayouts { guard let beforeItemLayout = beforeItemLayoutMap[afterItemLayout.interactionUniqueId] else { continue } return calculateAdjustment(beforeItemLayout: beforeItemLayout, afterItemLayout: afterItemLayout) } return nil } public func didPerformBatchUpdates() { AssertIsOnMainThread() owsAssertDebug(!isReloadingData) owsAssertDebug(isPerformingBatchUpdates) isPerformingBatchUpdates = false delegateScrollContinuityMode = .disabled if #available(iOS 13, *) { } else { // On iOS 12, we invalidate the layout immediately after performBatchUpdates() // to ensure that targetContentOffset(forProposedContentOffset:) is applied in a timely way. invalidateLayout() } } public func didCompleteBatchUpdates() { AssertIsOnMainThread() updateCompletionCounter.decrementOrZero() } public func willReloadData() { AssertIsOnMainThread() owsAssertDebug(!isReloadingData) owsAssertDebug(!isPerformingBatchUpdates) owsAssertDebug(delegateScrollContinuityMode == .disabled) isReloadingData = true updateCompletionCounter.increment() // TODO: We _could_ use the invalidation context for scroll // continuity here. let lastKnownDistanceFromBottom = delegate?.conversationViewController?.lastKnownDistanceFromBottom ?? 0 delegateScrollContinuityMode = .enabled(lastKnownDistanceFromBottom: lastKnownDistanceFromBottom) } public func didReloadData() { AssertIsOnMainThread() owsAssertDebug(isReloadingData) owsAssertDebug(!isPerformingBatchUpdates) isReloadingData = false updateCompletionCounter.decrementOrZero() delegateScrollContinuityMode = .disabled } public func buildScrollContinuityToken() -> CVScrollContinuityToken { AssertIsOnMainThread() let layoutInfo = ensureCurrentLayoutInfo() let contentOffset = collectionView?.contentOffset ?? .zero let visibleUniqueIds: [String] = { guard let collectionView = self.collectionView, let conversationViewController = delegate?.conversationViewController else { Logger.warn("Missing conversationViewController.") return [] } let visibleIndexPaths = collectionView.indexPathsForVisibleItems return visibleIndexPaths.compactMap { indexPath -> String? in guard let interaction = conversationViewController.interaction(forIndexPath: indexPath), Self.canInteractionBeUsedForScrollContinuity(interaction) else { return nil } return interaction.uniqueId } }() return CVScrollContinuityToken(layoutInfo: layoutInfo, contentOffset: contentOffset, visibleUniqueIds: visibleUniqueIds) } // Some interactions shift around and cannot be reliably used as // references for scroll continuity. private static func canInteractionBeUsedForScrollContinuity(_ interaction: TSInteraction) -> Bool { guard !interaction.isDynamicInteraction() else { return false } switch interaction.interactionType() { case .unknown, .unreadIndicator, .dateHeader, .typingIndicator: return false case .incomingMessage, .outgoingMessage, .error, .call, .info, .threadDetails, .unknownThreadWarning, .defaultDisappearingMessageTimer: return true } } // This method is called when there is an update with deletes/inserts to the collection view. // // It will be called prior to calling the initial/final layout attribute methods below to give // the layout an opportunity to do batch computations for the insertion and deletion layout attributes. // // The updateItems parameter is an array of UICollectionViewUpdateItem instances for each // element that is moving to a new index path. public override func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) { super.prepare(forCollectionViewUpdates: updateItems) } // Called inside an animation block after the update. public override func finalizeCollectionViewUpdates() { super.finalizeCollectionViewUpdates() } // UICollectionView calls this when its bounds have changed inside an // animation block before displaying cells in its new bounds. public override func prepare(forAnimatedBoundsChange oldBounds: CGRect) { super.prepare(forAnimatedBoundsChange: oldBounds) } // also called inside the animation block public override func finalizeAnimatedBoundsChange() { super.finalizeAnimatedBoundsChange() } private var isUserScrolling: Bool { delegate?.conversationViewController?.isUserScrolling ?? false } private var hasScrollingAnimation: Bool { delegate?.conversationViewController?.hasScrollingAnimation ?? false } private var debugInfo: String { "isUserScrolling: \(isUserScrolling), hasScrollingAnimation: \(hasScrollingAnimation), " + "isPerformingBatchUpdates: \(isPerformingBatchUpdates), " + "isReloadingData: \(isReloadingData), " + "isPerformBatchUpdatesOrReloadDataBeingApplied: \(isPerformBatchUpdatesOrReloadDataBeingApplied), " + "isPerformBatchUpdatesOrReloadDataBeingAppliedOrSettling: \(isPerformBatchUpdatesOrReloadDataBeingAppliedOrSettling), " } // MARK: - // A layout can return the content offset to be applied during transition or update animations. public override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint { targetContentOffset(proposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity) } // A layout can return the content offset to be applied during transition or update animations. public override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint { targetContentOffset(proposedContentOffset: proposedContentOffset, withScrollingVelocity: nil) } private func targetContentOffset(proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint?) -> CGPoint { guard let delegate = delegate else { owsFailDebug("Missing delegate.") if let velocity = velocity { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity) } else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset) } } guard velocity == nil else { return proposedContentOffset } // While applying reloadData() and performBatchUpdates(), allow CVC // to maintain scroll continuity. switch delegateScrollContinuityMode { case .disabled: break case .enabled(let lastKnownDistanceFromBottom): if let conversationViewController = delegate.conversationViewController { let targetContentOffset = conversationViewController.targetContentOffset(forProposedContentOffset: proposedContentOffset, lastKnownDistanceFromBottom: lastKnownDistanceFromBottom) return targetContentOffset } case .enabledIOS12(let token, let isRelativeToTop, let lastKnownDistanceFromBottom): if let lastKnownDistanceFromBottom = lastKnownDistanceFromBottom, abs(lastKnownDistanceFromBottom) < 5 { // If the user was scrolled to the bottom, use the "delegate" // scroll continuity mechanism. } else { let layoutInfoCurrent = ensureCurrentLayoutInfo() if let targetContentOffset = Self.targetContentOffsetForUpdate(delegate: delegate, token: token, isRelativeToTop: isRelativeToTop, layoutInfoAfterUpdate: layoutInfoCurrent) { return targetContentOffset } } if let conversationViewController = delegate.conversationViewController { let targetContentOffset = conversationViewController.targetContentOffset(forProposedContentOffset: proposedContentOffset, lastKnownDistanceFromBottom: lastKnownDistanceFromBottom) return targetContentOffset } } return proposedContentOffset } private static func targetContentOffsetForUpdate(delegate: ConversationViewLayoutDelegate, token: CVScrollContinuityToken, isRelativeToTop: Bool, layoutInfoAfterUpdate: LayoutInfo) -> CGPoint? { let layoutInfoBeforeUpdate = token.layoutInfo let contentOffsetBeforeUpdate = token.contentOffset var beforeItemLayoutMap = [String: ItemLayout]() for itemLayout in layoutInfoBeforeUpdate.itemLayouts { beforeItemLayoutMap[itemLayout.interactionUniqueId] = itemLayout } // Honor the scroll continuity bias. // // If we prefer continuity with regard to the bottom // of the conversation, start with the last items. let afterItemLayouts = (isRelativeToTop ? layoutInfoAfterUpdate.itemLayouts : layoutInfoAfterUpdate.itemLayouts.reversed()) for afterItemLayout in afterItemLayouts { guard let beforeItemLayout = beforeItemLayoutMap[afterItemLayout.interactionUniqueId] else { continue } let frameBeforeUpdate = beforeItemLayout.layoutAttributes.frame let frameAfterUpdate = afterItemLayout.layoutAttributes.frame let offset = frameAfterUpdate.origin - frameBeforeUpdate.origin let updatedContentOffset = CGPoint(x: 0, y: (contentOffsetBeforeUpdate + offset).y) return updatedContentOffset } Logger.verbose("No continuity match.") return nil } public override var debugDescription: String { ensureCurrentLayoutInfo().debugDescription } } // MARK: - // TODO: This might not have to be @objc after the CVC port. public class CVScrollContinuityToken: NSObject { fileprivate let layoutInfo: ConversationViewLayout.LayoutInfo fileprivate let contentOffset: CGPoint fileprivate let visibleUniqueIds: [String] fileprivate init(layoutInfo: ConversationViewLayout.LayoutInfo, contentOffset: CGPoint, visibleUniqueIds: [String]) { self.layoutInfo = layoutInfo self.contentOffset = contentOffset self.visibleUniqueIds = visibleUniqueIds } }