// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // public import SignalServiceKit public import SignalUI 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: - public protocol ConversationViewLayoutItem { var interactionUniqueId: String { get } var cellSize: CGSize { get } func vSpacing(previousLayoutItem: ConversationViewLayoutItem) -> CGFloat var canBeUsedForContinuity: Bool { get } var isDateHeader: Bool { get } } // 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 let canBeUsedForContinuity: Bool let isStickyHeader: Bool var frame: CGRect { layoutAttributes.frame } } fileprivate class LayoutInfo { let viewWidth: CGFloat let contentSize: CGSize let layoutAttributesMap: [Int: UICollectionViewLayoutAttributes] let headerLayoutAttributes: UICollectionViewLayoutAttributes? let footerLayoutAttributes: UICollectionViewLayoutAttributes? let itemLayouts: [ItemLayout] let renderStateId: UInt required init( viewWidth: CGFloat, contentSize: CGSize, layoutAttributesMap: [Int: UICollectionViewLayoutAttributes], headerLayoutAttributes: UICollectionViewLayoutAttributes?, footerLayoutAttributes: UICollectionViewLayoutAttributes?, itemLayouts: [ItemLayout], renderStateId: UInt, ) { self.viewWidth = viewWidth self.contentSize = contentSize self.layoutAttributesMap = layoutAttributesMap 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 < layoutAttributesMap.count) } return layoutAttributesMap[indexPath.row] } func layoutAttributesForSupplementaryElement( ofKind elementKind: String, at indexPath: IndexPath, ) -> UICollectionViewLayoutAttributes? { if elementKind == UICollectionView.elementKindSectionHeader, let headerLayoutAttributes, headerLayoutAttributes.indexPath == indexPath { return headerLayoutAttributes } if elementKind == UICollectionView.elementKindSectionFooter, let footerLayoutAttributes, footerLayoutAttributes.indexPath == indexPath { return footerLayoutAttributes } return nil } var debugDescription: String { var result = "[" for item in layoutAttributesMap.keys.sorted() { guard let layoutAttributes = layoutAttributesMap[item] else { owsFailDebug("Missing attributes for item: \(item)") continue } result += "item: \(layoutAttributes.indexPath), " } if let headerLayoutAttributes { result += "header: \(headerLayoutAttributes.indexPath), " } if let footerLayoutAttributes { result += "footer: \(footerLayoutAttributes.indexPath), " } result += "]" return result } } private var hasEverHadLayout = false // * currentLayoutInfo is the "at rest" layout. // * translatedLayoutInfo is the same layout, with ephemeral changes // for sticky date headers. private var currentLayoutInfo: LayoutInfo? { didSet { // We need to clear our "translated" layout info cache if the // "default" layout info changes, since the "translated" state // is derived from the "default" state. translatedLayoutInfo = nil } } private func ensureCurrentLayoutInfo() -> LayoutInfo { AssertIsOnMainThread() if let layoutInfo = currentLayoutInfo { return layoutInfo } ensureState() let layoutInfo = Self.buildLayoutInfo(state: currentState) currentLayoutInfo = layoutInfo hasEverHadLayout = true return layoutInfo } private class TranslatedLayoutInfo { let layoutInfo: LayoutInfo let collectionViewSize: CGSize let contentOffset: CGPoint let contentInset: UIEdgeInsets init( layoutInfo: LayoutInfo, collectionViewSize: CGSize, contentOffset: CGPoint, contentInset: UIEdgeInsets, ) { self.layoutInfo = layoutInfo self.collectionViewSize = collectionViewSize self.contentOffset = contentOffset self.contentInset = contentInset } } // * currentLayoutInfo is the "at rest" layout. // * translatedLayoutInfo is the same layout, with ephemeral changes // for sticky date headers. private var translatedLayoutInfo: TranslatedLayoutInfo? private func clearTranslatedLayoutInfoIfNecessary() { guard let collectionView = self.collectionView, let translatedLayoutInfo = self.translatedLayoutInfo else { return } let collectionViewSize = collectionView.bounds.size let contentOffset = collectionView.contentOffset let contentInset = collectionView.contentInset let didChange = ( translatedLayoutInfo.collectionViewSize != collectionViewSize || translatedLayoutInfo.contentOffset != contentOffset || translatedLayoutInfo.contentInset != contentInset, ) guard didChange else { return } // We need to clear our "translated" layout info cache if any of // the collection view state changes. self.translatedLayoutInfo = nil } private func ensureTranslatedLayoutInfo() -> LayoutInfo { AssertIsOnMainThread() // Use cached value if possible. if let translatedLayoutInfo = self.translatedLayoutInfo { return translatedLayoutInfo.layoutInfo } let layoutInfo = ensureCurrentLayoutInfo() guard let collectionView = self.collectionView else { owsFailDebug("Missing view.") return layoutInfo } let collectionViewSize = collectionView.bounds.size let contentOffset = collectionView.contentOffset let contentInset = collectionView.adjustedContentInset // The spacing between the sticky header and the navbar. let navBarSpacing: CGFloat = 12 // We want the sticky headers to stick just below the navbar, // with a small spacing. let topInset = contentInset.top + navBarSpacing let topOfViewportY = contentOffset.y + topInset // The minimum spacing between the sticky header and the next header. let minDateHeaderSpacing: CGFloat = 5 func isDateHeaderInOrBelowViewport(itemLayout: ItemLayout) -> Bool { let frame = itemLayout.layoutAttributes.frame return frame.y >= topOfViewportY } // Find all date headers. var dateHeaderItemLayouts = [ItemLayout]() for itemLayout in layoutInfo.itemLayouts { guard itemLayout.isStickyHeader else { continue } dateHeaderItemLayouts.append(itemLayout) } // Sort the date headers. dateHeaderItemLayouts.sort { left, right in left.frame.y < right.frame.y } // The sticky date header is either: // // * The last date header if no date headers are in or below the viewport. // // DH DH // DH DH // DH ↓ // - DH - <- Stick to top of viewport // | | // | ViewPort -> | ViewPort // | | // - - // // * The date header just above the last date header in or below the viewport. // // DH DH // DH DH // DH ↓ // - DH - <- Stick to top of viewport // | | // | ViewPort -> | ViewPort // | | // DH | DH | <- Last Header in or below the viewport. // - - // DH DH // DH DH // // Therefore we trim the (ordered) list of date headers until there is // _at most_ one date header in or below the viewport (it will be last if // present). while true { let lastTwoDateHeaders = dateHeaderItemLayouts.suffix(2) guard lastTwoDateHeaders.count == 2, let lastDateHeader = lastTwoDateHeaders.last, let penultimateDateHeader = lastTwoDateHeaders.first else { // Not enough date headers to continue trimming. break } if isDateHeaderInOrBelowViewport(itemLayout: lastDateHeader), isDateHeaderInOrBelowViewport(itemLayout: penultimateDateHeader) { _ = dateHeaderItemLayouts.popLast() continue } else { // No need to continue trimming. break } } struct StickyDateHeader { let prevDateHeader: ItemLayout? let dateHeaderToStick: ItemLayout let nextDateHeader: ItemLayout? } func findDateHeaderToStick() -> StickyDateHeader? { // This might contain item layouts for 0, 1 or 2 date headers. guard let lastDateHeader = dateHeaderItemLayouts[back: 0] else { // No date headers, nothing to stick. return nil } guard let penultimateDateHeader = dateHeaderItemLayouts[back: 1] else { if isDateHeaderInOrBelowViewport(itemLayout: lastDateHeader) { // All date headers are in or below viewport, nothing to stick. return nil } else { // There's only one date header and it's above the viewport; // it should stick. return StickyDateHeader( prevDateHeader: nil, dateHeaderToStick: lastDateHeader, nextDateHeader: nil, ) } } let prevDateHeader: ItemLayout? = dateHeaderItemLayouts[back: 2] if isDateHeaderInOrBelowViewport(itemLayout: lastDateHeader) { owsAssertDebug(!isDateHeaderInOrBelowViewport(itemLayout: penultimateDateHeader)) // We found the last date header just above the first date header that // is in or below the viewport; it should stick. return StickyDateHeader( prevDateHeader: prevDateHeader, dateHeaderToStick: penultimateDateHeader, nextDateHeader: lastDateHeader, ) } else { // There's last date header is above the viewport; // it should stick. return StickyDateHeader( prevDateHeader: prevDateHeader, dateHeaderToStick: lastDateHeader, nextDateHeader: nil, ) } } guard let dateHeaderToStick = findDateHeaderToStick() else { // No date header to stick; no translation is needed. return layoutInfo } let stickyDateHeader = dateHeaderToStick.dateHeaderToStick var layoutAttributesMap = layoutInfo.layoutAttributesMap var itemLayouts = layoutInfo.itemLayouts func updateItemLayout(_ newItemLayout: ItemLayout) { // Update layoutAttributesMap. layoutAttributesMap[newItemLayout.indexPath.row] = newItemLayout.layoutAttributes // Update itemLayouts. itemLayouts = itemLayouts.map { (itemLayout: ItemLayout) -> ItemLayout in if itemLayout.indexPath == newItemLayout.indexPath { // Replace this itemLayout with the stickyItemLayout return newItemLayout } else { return itemLayout } } } // "At rest", the sticky header should be aligned with the top of the viewport, // with a small spacing. let stickyHeaderY_normal = stickyDateHeader.frame.y var stickyHeaderY_stuck = topOfViewportY if let nextDateHeader = dateHeaderToStick.nextDateHeader { let maxStickyY = nextDateHeader.frame.y - (stickyDateHeader.frame.height + minDateHeaderSpacing) stickyHeaderY_stuck = min(stickyHeaderY_stuck, maxStickyY) } // Update the ItemLayout for the "stuck" sticky header. do { let stickyItemLayout: ItemLayout = { let indexPath = stickyDateHeader.indexPath let layoutAttributes = CVCollectionViewLayoutAttributes(forCellWith: indexPath) var frame = stickyDateHeader.frame frame.y = stickyHeaderY_stuck layoutAttributes.frame = frame layoutAttributes.zIndex = Self.zIndexStickyHeader layoutAttributes.isStickyHeader = true return ItemLayout( interactionUniqueId: stickyDateHeader.interactionUniqueId, indexPath: indexPath, layoutAttributes: layoutAttributes, canBeUsedForContinuity: stickyDateHeader.canBeUsedForContinuity, isStickyHeader: stickyDateHeader.isStickyHeader, ) }() updateItemLayout(stickyItemLayout) } // Update the ItemLayout for the previous date header. // This ensures an orderly transition out after it has become // "unstuck" if let prevDateHeader = dateHeaderToStick.prevDateHeader { let prevItemLayout: ItemLayout = { let indexPath = prevDateHeader.indexPath let layoutAttributes = CVCollectionViewLayoutAttributes(forCellWith: indexPath) var frame = prevDateHeader.frame frame.y = stickyHeaderY_normal - (frame.height + minDateHeaderSpacing) layoutAttributes.frame = frame layoutAttributes.zIndex = Self.zIndexStickyHeader layoutAttributes.isStickyHeader = true return ItemLayout( interactionUniqueId: prevDateHeader.interactionUniqueId, indexPath: indexPath, layoutAttributes: layoutAttributes, canBeUsedForContinuity: prevDateHeader.canBeUsedForContinuity, isStickyHeader: prevDateHeader.isStickyHeader, ) }() updateItemLayout(prevItemLayout) } let adjustedLayoutInfo = LayoutInfo( viewWidth: layoutInfo.viewWidth, contentSize: layoutInfo.contentSize, layoutAttributesMap: layoutAttributesMap, headerLayoutAttributes: layoutInfo.headerLayoutAttributes, footerLayoutAttributes: layoutInfo.footerLayoutAttributes, itemLayouts: itemLayouts, renderStateId: layoutInfo.renderStateId, ) // Update the cache. self.translatedLayoutInfo = TranslatedLayoutInfo( layoutInfo: adjustedLayoutInfo, collectionViewSize: collectionViewSize, contentOffset: contentOffset, contentInset: contentInset, ) return adjustedLayoutInfo } override public class var layoutAttributesClass: AnyClass { CVCollectionViewLayoutAttributes.self } public required init(conversationStyle: ConversationStyle) { self.conversationStyle = conversationStyle super.init() } @available(*, unavailable, message: "Use other constructor instead.") public required init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } public func update(conversationStyle: ConversationStyle) { AssertIsOnMainThread() guard self.conversationStyle != conversationStyle else { return } self.conversationStyle = conversationStyle invalidateLayout() } override public 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() } override public 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 } override public func prepare() { super.prepare() _ = ensureCurrentLayoutInfo() clearTranslatedLayoutInfoIfNecessary() } 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 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 == 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, layoutAttributesMap: [:], headerLayoutAttributes: nil, footerLayoutAttributes: nil, itemLayouts: [], renderStateId: 0, ) } guard let 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 layoutAttributesMap = [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 = CVCollectionViewLayoutAttributes( 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 { 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 layoutAttributes = CVCollectionViewLayoutAttributes(forCellWith: indexPath) layoutAttributes.frame = itemFrame if layoutItem.isDateHeader { layoutAttributes.zIndex = Self.zIndexStickyHeader } else { layoutAttributes.zIndex = Self.zIndexDefault } layoutAttributesMap[row] = layoutAttributes contentBottom = itemFrame.origin.y + itemFrame.size.height y = contentBottom row += 1 previousLayoutItem = layoutItem itemLayouts.append(ItemLayout( interactionUniqueId: layoutItem.interactionUniqueId, indexPath: indexPath, layoutAttributes: layoutAttributes, canBeUsedForContinuity: layoutItem.canBeUsedForContinuity, isStickyHeader: layoutItem.isDateHeader, )) } 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 = CVCollectionViewLayoutAttributes( 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, layoutAttributesMap: layoutAttributesMap, headerLayoutAttributes: headerLayoutAttributes, footerLayoutAttributes: footerLayoutAttributes, itemLayouts: itemLayouts, renderStateId: renderStateId, ) } private static let zIndexDefault: Int = 1 private static let zIndexStickyHeader: Int = 2 // MARK: - UICollectionViewLayout Impl. override public func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { AssertIsOnMainThread() // Return values from the "translated" layout info. let layoutInfo = ensureTranslatedLayoutInfo() 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) } } override public func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { AssertIsOnMainThread() // Return values from the "translated" layout info. let layoutInfo = ensureTranslatedLayoutInfo() return layoutInfo.layoutAttributesForItem(at: indexPath, assertIfMissing: true) } override public func layoutAttributesForSupplementaryView( ofKind elementKind: String, at indexPath: IndexPath, ) -> UICollectionViewLayoutAttributes? { AssertIsOnMainThread() // Return values from the "translated" layout info. let layoutInfo = ensureTranslatedLayoutInfo() return layoutInfo.layoutAttributesForSupplementaryElement( ofKind: elementKind, at: indexPath, ) } override public var collectionViewContentSize: CGSize { AssertIsOnMainThread() return ensureCurrentLayoutInfo().contentSize } override public 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?) } private var delegateScrollContinuityMode: DelegateScrollContinuityMode = .disabled // Returns true during performBatchUpdates() or reloadData(). This returns // true after performBatchUpdates() returns, before its completion is // called. public var isPerformBatchUpdatesOrReloadDataBeingApplied: Bool { isPerformingBatchUpdates || isReloadingData } public func willPerformBatchUpdates( scrollContinuity: ScrollContinuity, lastKnownDistanceFromBottom: CGFloat?, ) { AssertIsOnMainThread() owsAssertDebug(!isReloadingData) owsAssertDebug(!isPerformingBatchUpdates) owsAssertDebug(delegateScrollContinuityMode == .disabled) isPerformingBatchUpdates = true delegateScrollContinuityMode = .disabled switch scrollContinuity { case .none: break case .contentRelativeToViewport(let token, let isRelativeToTop): if !applyContentOffsetAdjustmentIfNecessary( scrollContinuityToken: token, isRelativeToTop: isRelativeToTop, ) { delegateScrollContinuityMode = .enabled(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.frame let frameAfterUpdate = afterItemLayout.frame let offset = frameAfterUpdate.origin - frameBeforeUpdate.origin let contentOffsetAdjustment = CGPoint(x: 0, y: offset.y) return contentOffsetAdjustment } if let anchorInteractionId = scrollContinuityToken.anchorInteractionId, let beforeItemLayout = beforeItemLayoutMap[anchorInteractionId], let afterItemLayout = afterItemLayoutMap[anchorInteractionId] { if beforeItemLayout.canBeUsedForContinuity, afterItemLayout.canBeUsedForContinuity { return calculateAdjustment( beforeItemLayout: beforeItemLayout, afterItemLayout: afterItemLayout, ) } else { owsFailDebug("Invalid scroll continuity anchor.") } } // 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 } public func willReloadData() { AssertIsOnMainThread() owsAssertDebug(!isReloadingData) owsAssertDebug(!isPerformingBatchUpdates) owsAssertDebug(delegateScrollContinuityMode == .disabled) isReloadingData = true // 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 delegateScrollContinuityMode = .disabled } public func buildScrollContinuityToken(preferredAnchorInteractionId: String? = nil) -> CVScrollContinuityToken { AssertIsOnMainThread() let layoutInfo = ensureCurrentLayoutInfo() let contentOffset = collectionView?.contentOffset ?? .zero let visibleUniqueIds: [String] = { guard let collectionView = self.collectionView else { Logger.warn("Missing collectionView.") return [] } let visibleIndexPaths = collectionView.indexPathsForVisibleItems return visibleIndexPaths.compactMap { indexPath -> String? in guard let layoutInfo = layoutInfo.itemLayouts[safe: indexPath.row], layoutInfo.canBeUsedForContinuity else { return nil } return layoutInfo.interactionUniqueId } }() return CVScrollContinuityToken( layoutInfo: layoutInfo, contentOffset: contentOffset, visibleUniqueIds: visibleUniqueIds, anchorInteractionId: preferredAnchorInteractionId, ) } // Some interactions shift around and cannot be reliably used as // references for scroll continuity. public static func canInteractionBeUsedForScrollContinuity(_ interaction: TSInteraction) -> Bool { switch interaction.interactionType { case .unknown, .unreadIndicator, .dateHeader, .typingIndicator, .threadDetails, .defaultDisappearingMessageTimer: return false case .incomingMessage, .outgoingMessage, .error, .call, .info, .unknownThreadWarning, .collapseSet: return true } } 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), " } // MARK: - // A layout can return the content offset to be applied during transition or update animations. override public 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. override public func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint { targetContentOffset( proposedContentOffset: proposedContentOffset, withScrollingVelocity: nil, ) } private func targetContentOffset( proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint?, ) -> CGPoint { guard let delegate else { owsFailDebug("Missing delegate.") if let 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 } } return proposedContentOffset } override public 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 let anchorInteractionId: String? fileprivate init( layoutInfo: ConversationViewLayout.LayoutInfo, contentOffset: CGPoint, visibleUniqueIds: [String], anchorInteractionId: String? = nil, ) { self.layoutInfo = layoutInfo self.contentOffset = contentOffset self.visibleUniqueIds = visibleUniqueIds self.anchorInteractionId = anchorInteractionId } } // MARK: - public class CVCollectionViewLayoutAttributes: UICollectionViewLayoutAttributes { public var isStickyHeader: Bool = false override public func copy(with zone: NSZone? = nil) -> Any { let copy = super.copy(with: zone) as! CVCollectionViewLayoutAttributes copy.isStickyHeader = isStickyHeader return copy } override public func isEqual(_ object: Any?) -> Bool { guard let object = object as? CVCollectionViewLayoutAttributes else { return false } guard object.isStickyHeader == self.isStickyHeader else { return false } return super.isEqual(object) } } // MARK: - extension Array { subscript(back i: Int) -> Iterator.Element? { self[safe: index(endIndex, offsetBy: -(i + 1))] } }