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

667 lines
27 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import SignalServiceKit
import SignalUI
public enum ScrollAlignment: Int {
case top
case bottom
case center
// These match the behavior of UICollectionView.ScrollPosition and
// noop if the view is already entirely on screen.
case topIfNotEntirelyOnScreen
case bottomIfNotEntirelyOnScreen
case centerIfNotEntirelyOnScreen
var scrollsOnlyIfNotEntirelyOnScreen: Bool {
switch self {
case .top, .bottom, .center:
return false
case .topIfNotEntirelyOnScreen,
.bottomIfNotEntirelyOnScreen,
.centerIfNotEntirelyOnScreen:
return true
}
}
}
// MARK: -
// TODO: Do we need to specify the load alignment (top, bottom, center)
// or that implicit in the value?
public struct CVScrollAction: Equatable, CustomStringConvertible {
// TODO: Do we need to specify the load alignment (top, bottom, center)
// or that implicit in the value?
public enum Action: Equatable, CustomStringConvertible {
case none
case scrollTo(interactionId: String, onScreenPercentage: CGFloat, alignment: ScrollAlignment)
case bottomOfLoadWindow
case initialPosition
case bottomForNewMessage
// MARK: - CustomStringConvertible
public var description: String {
switch self {
case .none:
return "none"
case .scrollTo(let interactionId, _, _):
return "scrollTo(\(interactionId))"
case .bottomOfLoadWindow:
return "bottomOfLoadWindow"
case .initialPosition:
return "initialPosition"
case .bottomForNewMessage:
return "bottomForNewMessage"
}
}
}
let action: Action
let isAnimated: Bool
public static var none: CVScrollAction {
CVScrollAction(action: .none, isAnimated: false)
}
// MARK: - CustomStringConvertible
public var description: String {
"[scrollAction: \(action), isAnimated: \(isAnimated)]"
}
}
// MARK: -
extension ConversationViewController {
func perform(scrollAction: CVScrollAction) {
AssertIsOnMainThread()
switch scrollAction.action {
case .none:
break
case .scrollTo(let interactionId, let onScreenPercentage, let alignment):
if let indexPath = self.indexPath(forInteractionUniqueId: interactionId) {
// TODO: Set position and animated.
scrollToInteraction(
indexPath: indexPath,
onScreenPercentage: onScreenPercentage,
alignment: alignment,
animated: scrollAction.isAnimated
)
} else {
owsFailDebug("Could not locate interaction.")
}
case .bottomOfLoadWindow, .bottomForNewMessage:
scrollToBottomOfLoadWindow(animated: scrollAction.isAnimated)
case .initialPosition:
scrollToInitialPosition(animated: scrollAction.isAnimated)
}
}
func scrollToTopOfLoadWindow(animated: Bool) {
guard let interactionId = renderItems.first?.interactionUniqueId else {
return
}
scrollToInteraction(uniqueId: interactionId, alignment: .top, animated: animated)
}
func scrollToBottomOfLoadWindow(animated: Bool) {
let newContentOffset = CGPoint(x: 0, y: maxContentOffsetY)
collectionView.setContentOffset(newContentOffset, animated: animated)
}
func scrollToInitialPosition(animated: Bool) {
guard loadCoordinator.hasRenderState else {
// TODO: We should scroll to default position after first load completes.
return
}
guard let initialScrollState = initialScrollState else {
owsAssertDebug(hasViewDidAppearEverBegun)
return
}
// TODO: Should we load any of these interactions before we scroll?
if let focusMessageId = initialScrollState.focusMessageId {
if focusMessageId == lastVisibleInteractionWithSneakyTransaction()?.uniqueId {
scrollToLastVisibleInteraction(animated: animated)
return
} else if let indexPath = indexPath(forInteractionUniqueId: focusMessageId) {
scrollToInteraction(
indexPath: indexPath,
alignment: .top,
animated: animated
)
return
} else if hasRenderState {
owsFailDebug("focusMessageId not in the load window.")
}
}
if let indexPath = indexPathOfUnreadMessagesIndicator {
scrollToInteraction(
indexPath: indexPath,
alignment: .top,
animated: animated
)
} else {
scrollToLastVisibleInteraction(animated: animated)
}
}
// This method scrolls to the bottom of the _conversation_,
// not the load window.
func scrollToBottomOfConversation(animated: Bool) {
if canLoadNewerItems {
loadCoordinator.loadAndScrollToNewestItems(isAnimated: animated)
} else {
scrollToBottomOfLoadWindow(animated: animated)
}
}
func scrollToLastVisibleInteraction(animated: Bool) {
guard let lastVisibleInteraction = lastVisibleInteractionWithSneakyTransaction() else {
return scrollToBottomOfConversation(animated: animated)
}
// IFF the lastVisibleInteraction is the last non-dynamic interaction in the thread,
// we want to scroll to the bottom to also show any active typing indicators.
if lastVisibleInteraction.sortId == lastSortIdInLoadedWindow,
SSKEnvironment.shared.typingIndicatorsRef.typingAddress(forThread: thread) != nil {
return scrollToBottomOfConversation(animated: animated)
}
guard let indexPath = indexPath(forInteractionUniqueId: lastVisibleInteraction.uniqueId) else {
owsFailDebug("No index path for interaction, scrolling to bottom")
scrollToBottomOfConversation(animated: animated)
return
}
scrollToInteraction(
indexPath: indexPath,
onScreenPercentage: CGFloat(lastVisibleInteraction.onScreenPercentage),
alignment: .bottom,
animated: animated
)
}
func scrollToInteraction(uniqueId: String,
onScreenPercentage: CGFloat = 1,
alignment: ScrollAlignment,
animated: Bool) {
guard let indexPath = indexPath(forInteractionUniqueId: uniqueId) else {
owsFailDebug("No index path for interaction, scrolling to bottom")
return
}
scrollToInteraction(indexPath: indexPath,
onScreenPercentage: onScreenPercentage,
alignment: alignment,
animated: animated)
}
func scrollToInteraction(indexPath: IndexPath,
onScreenPercentage: CGFloat = 1,
alignment: ScrollAlignment,
animated: Bool = true) {
guard !isUserScrolling else { return }
view.layoutIfNeeded()
guard let attributes = layout.layoutAttributesForItem(at: indexPath) else {
return owsFailDebug("failed to get attributes for indexPath \(indexPath)")
}
let topInset = collectionView.adjustedContentInset.top
let bottomInset = collectionView.adjustedContentInset.bottom
let collectionViewHeightUnobscuredByBottomBar = collectionView.height - bottomInset
let topDestinationY = topInset
let bottomDestinationY = safeContentHeight - collectionViewHeightUnobscuredByBottomBar
let currentMinimumVisibleOffset = collectionView.contentOffset.y + topInset
let currentMaximumVisibleOffset = collectionView.contentOffset.y + collectionViewHeightUnobscuredByBottomBar
let rowIsEntirelyOnScreen = attributes.frame.minY > currentMinimumVisibleOffset
&& attributes.frame.maxY < currentMaximumVisibleOffset
// If the collection view contents aren't scrollable, do nothing.
guard safeContentHeight > collectionViewHeightUnobscuredByBottomBar else { return }
// If the destination row is entirely visible AND the desired position
// is only valid for when the view is not on screen, do nothing.
guard !alignment.scrollsOnlyIfNotEntirelyOnScreen || !rowIsEntirelyOnScreen else { return }
guard indexPath != lastIndexPathInLoadedWindow || !onScreenPercentage.isEqual(to: 1) else {
// If we're scrolling to the last index AND we want it entirely on screen,
// scroll directly to the bottom regardless of the requested destination.
let contentOffset = CGPoint(x: 0, y: bottomDestinationY)
collectionView.setContentOffset(contentOffset, animated: animated)
updateLastKnownDistanceFromBottom()
return
}
var destinationY: CGFloat
switch alignment {
case .top, .topIfNotEntirelyOnScreen:
destinationY = attributes.frame.minY - topInset
destinationY += attributes.frame.height * (1 - onScreenPercentage)
case .bottom, .bottomIfNotEntirelyOnScreen:
destinationY = attributes.frame.minY
destinationY -= collectionViewHeightUnobscuredByBottomBar
destinationY += attributes.frame.height * onScreenPercentage
case .center, .centerIfNotEntirelyOnScreen:
assert(onScreenPercentage.isEqual(to: 1))
destinationY = attributes.frame.midY
destinationY -= collectionView.height / 2
}
// If the target destination would cause us to scroll beyond
// the top of the collection view, scroll to top
if destinationY < topDestinationY { destinationY = topDestinationY }
// If the target destination would cause us to scroll beyond
// the bottom of the collection view, scroll to bottom
else if destinationY > bottomDestinationY { destinationY = bottomDestinationY }
let contentOffset = CGPoint(x: 0, y: destinationY)
collectionView.setContentOffset(contentOffset, animated: animated)
updateLastKnownDistanceFromBottom()
}
func scrollToQuotedMessage(_ quotedReply: QuotedReplyModel, isAnimated: Bool) {
if quotedReply.sourceOfOriginal == .remote {
presentRemotelySourcedQuotedReplyToast()
return
}
let quotedMessage: TSMessage?
if let timestamp = quotedReply.originalMessageTimestamp {
quotedMessage = SSKEnvironment.shared.databaseStorageRef.read { transaction in
InteractionFinder.findMessage(
withTimestamp: timestamp,
threadId: self.thread.uniqueId,
author: quotedReply.originalMessageAuthorAddress,
transaction: transaction
)
}
} else {
quotedMessage = nil
}
if let quotedMessage {
if quotedMessage.wasRemotelyDeleted {
presentMissingQuotedReplyToast()
return
}
let targetUniqueId: String
switch quotedMessage.editState {
case .latestRevisionRead, .latestRevisionUnread, .none:
targetUniqueId = quotedMessage.uniqueId
case .pastRevision:
// If this is an older edit revision, find the current
// edit and use that uniqueId instead of the old one.
let currentEdit = SSKEnvironment.shared.databaseStorageRef.read { transaction in
DependenciesBridge.shared.editMessageStore.findMessage(
fromEdit: quotedMessage,
tx: transaction.asV2Read
)
}
if let currentEdit {
targetUniqueId = currentEdit.uniqueId
} else {
owsFailDebug("Couldn't find original edit")
return
}
}
ensureInteractionLoadedThenScrollToInteraction(
targetUniqueId,
alignment: .centerIfNotEntirelyOnScreen,
isAnimated: isAnimated
)
}
}
func ensureInteractionLoadedThenScrollToInteraction(_ interactionId: String,
onScreenPercentage: CGFloat = 1,
alignment: ScrollAlignment,
isAnimated: Bool = true) {
if let indexPath = self.indexPath(forInteractionUniqueId: interactionId) {
self.scrollToInteraction(indexPath: indexPath,
onScreenPercentage: onScreenPercentage,
alignment: alignment,
animated: isAnimated)
} else {
loadCoordinator.enqueueLoadAndScrollToInteraction(interactionId: interactionId,
onScreenPercentage: onScreenPercentage,
alignment: alignment,
isAnimated: isAnimated)
}
}
func setScrollActionForSizeTransition() {
AssertIsOnMainThread()
owsAssertDebug(viewState.scrollActionForSizeTransition == nil)
viewState.scrollActionForSizeTransition = {
if self.isScrolledToBottom {
return CVScrollAction(action: .bottomOfLoadWindow, isAnimated: false)
}
guard let lastVisibleInteraction = lastVisibleInteractionWithSneakyTransaction() else {
return CVScrollAction(action: .bottomOfLoadWindow, isAnimated: false)
}
// IFF the lastVisibleInteraction is the last non-dynamic interaction in the thread,
// we want to scroll to the bottom to also show any active typing indicators.
if lastVisibleInteraction.sortId == lastSortIdInLoadedWindow,
SSKEnvironment.shared.typingIndicatorsRef.typingAddress(forThread: thread) != nil {
return CVScrollAction(action: .bottomOfLoadWindow, isAnimated: false)
}
if let lastKnownDistanceFromBottom = self.lastKnownDistanceFromBottom,
lastKnownDistanceFromBottom < 50 {
return CVScrollAction(action: .bottomOfLoadWindow, isAnimated: false)
}
return CVScrollAction(action: .scrollTo(interactionId: lastVisibleInteraction.uniqueId,
onScreenPercentage: lastVisibleInteraction.onScreenPercentage,
alignment: .bottom),
isAnimated: false)
}()
}
func clearScrollActionForSizeTransition() {
AssertIsOnMainThread()
owsAssertDebug(viewState.scrollActionForSizeTransition != nil)
if let scrollAction = viewState.scrollActionForSizeTransition {
owsAssertDebug(!scrollAction.isAnimated)
perform(scrollAction: scrollAction)
}
viewState.scrollActionForSizeTransition = nil
}
@objc
func scrollDownButtonTapped() {
AssertIsOnMainThread()
// TODO: I'm not sure this will do the right thing if there's an unread indicator
// below current scroll position but outside the load window, e.g. if we entered
// the conversation view a search result.
if let indexPathOfUnreadMessagesIndicator = self.indexPathOfUnreadMessagesIndicator {
let unreadRow = indexPathOfUnreadMessagesIndicator.row
var isScrolledAboveUnreadIndicator = true
let visibleIndices = collectionView.indexPathsForVisibleItems
for indexPath in visibleIndices {
if indexPath.row > unreadRow {
isScrolledAboveUnreadIndicator = false
break
}
}
if isScrolledAboveUnreadIndicator {
// Only scroll as far as the unread indicator if we're scrolled above the unread indicator.
scrollToInteraction(indexPath: indexPathOfUnreadMessagesIndicator,
onScreenPercentage: 1,
alignment: .top,
animated: true)
return
}
}
scrollToBottomOfConversation(animated: true)
}
public func recordInitialScrollState(_ focusMessageId: String?) {
initialScrollState = CVInitialScrollState(focusMessageId: focusMessageId)
}
public func clearInitialScrollState() {
initialScrollState = nil
}
@objc
func scrollToNextMentionButtonTapped() {
if let nextMessageId = conversationViewModel.unreadMentionMessageIds.first {
ensureInteractionLoadedThenScrollToInteraction(
nextMessageId,
alignment: .bottomIfNotEntirelyOnScreen,
isAnimated: true
)
}
}
@discardableResult
func updateLastKnownDistanceFromBottom() -> CGFloat? {
guard hasAppearedAndHasAppliedFirstLoad else {
return nil
}
let lastKnownDistanceFromBottom = self.safeDistanceFromBottom
self.lastKnownDistanceFromBottom = lastKnownDistanceFromBottom
return lastKnownDistanceFromBottom
}
// We use this hook to ensure scroll state continuity. As the collection
// view's content size changes, we want to keep the same cells in view.
func contentOffset(forLastKnownDistanceFromBottom distanceFromBottom: CGFloat) -> CGPoint {
// Adjust the content offset to reflect the "last known" distance
// from the bottom of the content.
let contentOffsetYBottom = maxContentOffsetY
var contentOffsetY = contentOffsetYBottom - max(0, distanceFromBottom)
let minContentOffsetY = -collectionView.safeAreaInsets.top
contentOffsetY = max(minContentOffsetY, contentOffsetY)
return CGPoint(x: 0, y: contentOffsetY)
}
var isScrolledToBottom: Bool {
isScrolledToBottom(tolerancePoints: 5)
}
func isScrolledToBottom(tolerancePoints: CGFloat) -> Bool {
safeDistanceFromBottom <= tolerancePoints
}
func isScrolledToTop(tolerancePoints: CGFloat) -> Bool {
safeDistanceFromTop <= tolerancePoints
}
public var safeDistanceFromTop: CGFloat {
collectionView.contentOffset.y - minContentOffsetY
}
public var safeDistanceFromBottom: CGFloat {
// This is a bit subtle.
//
// The _wrong_ way to determine if we're scrolled to the bottom is to
// measure whether the collection view's content is "near" the bottom edge
// of the collection view. This is wrong because the collection view
// might not have enough content to fill the collection view's bounds
// _under certain conditions_ (e.g. with the keyboard dismissed).
//
// What we're really interested in is something a bit more subtle:
// "Is the scroll view scrolled down as far as it can, "at rest".
//
// To determine that, we find the appropriate "content offset y" if
// the scroll view were scrolled down as far as possible. IFF the
// actual "content offset y" is "near" that value, we return YES.
maxContentOffsetY - collectionView.contentOffset.y
}
// The lowest valid content offset when the view is at rest.
private var minContentOffsetY: CGFloat {
-collectionView.adjustedContentInset.top
}
// The highest valid content offset when the view is at rest.
internal var maxContentOffsetY: CGFloat {
let contentHeight = self.safeContentHeight
let adjustedContentInset = collectionView.adjustedContentInset
let rawValue = contentHeight + adjustedContentInset.bottom - collectionView.bounds.size.height
// Note the usage of MAX() to handle the case where there isn't enough
// content to fill the collection view at its current size.
let clampedValue = max(minContentOffsetY, rawValue)
return clampedValue
}
// We use this hook to ensure scroll state continuity. As the collection
// view's content size changes, we want to keep the same cells in view.
public func targetContentOffset(
forProposedContentOffset proposedContentOffset: CGPoint,
lastKnownDistanceFromBottom: CGFloat?
) -> CGPoint {
// TODO: Consider handling these transitions using a scroll
// continuity token.
if let contentOffset = targetContentOffsetForSizeTransition() {
return contentOffset
}
// TODO: Consider handling these transitions using a scroll
// continuity token.
if let contentOffset = targetContentOffsetForUpdate() {
return contentOffset
}
// TODO: Can we improve this case?
if let contentOffset = targetContentOffsetForBottom(lastKnownDistanceFromBottom: lastKnownDistanceFromBottom) {
return contentOffset
}
return proposedContentOffset
}
var shouldUseDelegateScrollContinuity: Bool {
if let scrollAction = viewState.scrollActionForSizeTransition,
scrollAction != .none {
return true
}
if let scrollAction = viewState.scrollActionForUpdate {
switch scrollAction.action {
case .bottomOfLoadWindow, .scrollTo:
if !scrollAction.isAnimated {
return true
}
case .bottomForNewMessage:
return true
default:
break
}
}
return false
}
private func targetContentOffsetForBottom(lastKnownDistanceFromBottom: CGFloat?) -> CGPoint? {
guard let lastKnownDistanceFromBottom = self.lastKnownDistanceFromBottom else {
return nil
}
let contentOffset = self.contentOffset(forLastKnownDistanceFromBottom: lastKnownDistanceFromBottom)
return contentOffset
}
private func targetContentOffsetForSizeTransition() -> CGPoint? {
guard let scrollAction = viewState.scrollActionForSizeTransition else {
return nil
}
owsAssertDebug(!scrollAction.isAnimated)
return targetContentOffsetForScrollAction(scrollAction)
}
private func targetContentOffsetForUpdate() -> CGPoint? {
guard let scrollAction = viewState.scrollActionForUpdate else {
return nil
}
guard scrollAction.action != .none, !scrollAction.isAnimated else {
return nil
}
return targetContentOffsetForScrollAction(scrollAction)
}
private func targetContentOffsetForScrollAction(_ scrollAction: CVScrollAction) -> CGPoint? {
owsAssertDebug(!scrollAction.isAnimated)
switch scrollAction.action {
case .bottomOfLoadWindow, .bottomForNewMessage:
let minContentOffsetY = -collectionView.safeAreaInsets.top
var contentOffset = self.contentOffset(forLastKnownDistanceFromBottom: 0)
contentOffset.y = max(minContentOffsetY, contentOffset.y)
return contentOffset
case .scrollTo(let referenceUniqueId, let onScreenPercentage, _):
// Start with a content offset for being scrolled to the bottom.
var contentOffset = self.contentOffset(forLastKnownDistanceFromBottom: 0)
guard let referenceIndexPath = indexPath(forInteractionUniqueId: referenceUniqueId) else {
owsFailDebug("Missing referenceIndexPath.")
return nil
}
guard let referenceLayoutAttributes = layout.layoutAttributesForItem(at: referenceIndexPath) else {
owsFailDebug("Missing layoutAttributes.")
return nil
}
// Adjust content offset to reflect onScreenPercentage.
let onScreenAlpha = (1 - onScreenPercentage).clamp01()
contentOffset.y -= referenceLayoutAttributes.frame.height * onScreenAlpha
if let lastIndexPath = allIndexPaths.last,
let lastLayoutAttributes = layout.layoutAttributesForItem(at: lastIndexPath) {
// Only offset if the reference interaction is not last.
if lastIndexPath != referenceIndexPath {
owsAssertDebug(lastLayoutAttributes.frame.maxY > referenceLayoutAttributes.frame.maxY)
let distanceToLastInteraction = (lastLayoutAttributes.frame.maxY -
referenceLayoutAttributes.frame.maxY)
contentOffset.y -= distanceToLastInteraction
}
} else {
owsFailDebug("Missing lastIndexPath.")
}
let minContentOffsetY = -collectionView.safeAreaInsets.top
contentOffset.y = max(minContentOffsetY, contentOffset.y)
return contentOffset
default:
owsFailDebug("Invalid scroll action: \(scrollAction.description)")
return nil
}
}
// MARK: -
private struct LastVisibleInteraction {
public let interaction: TSInteraction
public let onScreenPercentage: CGFloat
public var sortId: UInt64 { interaction.sortId }
public var uniqueId: String { interaction.uniqueId }
}
public static func lastVisibleInteractionId(for thread: TSThread, tx: SDSAnyReadTransaction) -> String? {
return lastVisibleInteraction(for: thread, tx: tx)?.uniqueId
}
private func lastVisibleInteractionWithSneakyTransaction() -> LastVisibleInteraction? {
return SSKEnvironment.shared.databaseStorageRef.read { tx in Self.lastVisibleInteraction(for: thread, tx: tx) }
}
private static func lastVisibleInteraction(for thread: TSThread, tx: SDSAnyReadTransaction) -> LastVisibleInteraction? {
guard
let lastVisibleInteraction = DependenciesBridge.shared.lastVisibleInteractionStore
.lastVisibleInteraction(for: thread, tx: tx.asV2Read),
let interaction = thread.firstInteraction(atOrAroundSortId: lastVisibleInteraction.sortId, transaction: tx)
else {
return nil
}
let onScreenPercentage = lastVisibleInteraction.onScreenPercentage
return LastVisibleInteraction(interaction: interaction, onScreenPercentage: onScreenPercentage)
}
}