TM-SGNL-iOS/Signal/ConversationView/ConversationViewLayout.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

1046 lines
42 KiB
Swift

//
// 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,
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 layoutAttributesMap.keys.sorted() {
guard let layoutAttributes = layoutAttributesMap[item] else {
owsFailDebug("Missing attributes for item: \(item)")
continue
}
result += "item: \(layoutAttributes.indexPath), "
}
if let headerLayoutAttributes = headerLayoutAttributes {
result += "header: \(headerLayoutAttributes.indexPath), "
}
if let footerLayoutAttributes = 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
}
public override 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.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()
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 = 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,
layoutAttributesMap: [:],
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 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 = 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.
public override 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) }
}
public override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
AssertIsOnMainThread()
// Return values from the "translated" layout info.
let layoutInfo = ensureTranslatedLayoutInfo()
return layoutInfo.layoutAttributesForItem(at: indexPath, assertIfMissing: true)
}
public override 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)
}
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?)
}
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
}
// 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() -> 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)
}
// Some interactions shift around and cannot be reliably used as
// references for scroll continuity.
public 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
}
}
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.
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
}
}
return proposedContentOffset
}
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
}
}
// MARK: -
public class CVCollectionViewLayoutAttributes: UICollectionViewLayoutAttributes {
public var isStickyHeader: Bool = false
public override func copy(with zone: NSZone? = nil) -> Any {
let copy = super.copy(with: zone) as! CVCollectionViewLayoutAttributes
copy.isStickyHeader = isStickyHeader
return copy
}
public override 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))]
}
}