844 lines
34 KiB
Swift
844 lines
34 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|