// // 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) { viewState.highlightedMessageId = interactionId // TODO: Set position and animated. scrollToInteraction( indexPath: indexPath, interactionUniqueId: interactionId, 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 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, interactionUniqueId: focusMessageId, alignment: .top, animated: animated, ) return } else if hasRenderState { owsFailDebug("focusMessageId not in the load window.") } } if let indexPath = indexPathOfUnreadMessagesIndicator { scrollToInteraction( indexPath: indexPath, interactionUniqueId: nil, 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 renderedId = safeUniqueIdForScrolling(interactionUniqueId: lastVisibleInteraction.uniqueId), let indexPath = indexPath(forInteractionUniqueId: renderedId) else { owsFailDebug("No index path for interaction, scrolling to bottom") scrollToBottomOfConversation(animated: animated) return } scrollToInteraction( indexPath: indexPath, interactionUniqueId: renderedId, 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, interactionUniqueId: uniqueId, onScreenPercentage: onScreenPercentage, alignment: alignment, animated: animated, ) } func scrollToInteraction( indexPath: IndexPath, interactionUniqueId: String?, 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)") } viewState.focusedMessageId = interactionUniqueId 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 { performMessageHighlightAnimationIfNeeded() focusVoiceoverElementAfterScroll() 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 { performMessageHighlightAnimationIfNeeded() focusVoiceoverElementAfterScroll() 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() focusVoiceoverElementAfterScroll() 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, ) } 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) { viewState.highlightedMessageId = interactionId scrollToInteraction( indexPath: indexPath, interactionUniqueId: interactionId, onScreenPercentage: onScreenPercentage, alignment: alignment, animated: isAnimated, ) } else { expandCollapseSetContaining(interactionId: interactionId) loadCoordinator.enqueueLoadAndScrollToInteraction( interactionId: interactionId, onScreenPercentage: onScreenPercentage, alignment: alignment, isAnimated: isAnimated, ) } } /// Finds the uniqueId of the rendered item representing the given interaction. /// If the interaction is inside a CollapseSetInteraction, returns the set's uniqueId. private func safeUniqueIdForScrolling(interactionUniqueId: String) -> String? { if indexPath(forInteractionUniqueId: interactionUniqueId) != nil { return interactionUniqueId } return renderState.collapseSetUniqueId(forCollapsedInteractionId: interactionUniqueId) } private func expandCollapseSetContaining(interactionId: String) { guard let parentUniqueId = renderState.collapseSetUniqueId(forCollapsedInteractionId: interactionId) else { return } viewState.expandedCollapseSets.insert(parentUniqueId) loadCoordinator.enqueueReload() } 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) } let renderedId = safeUniqueIdForScrolling( interactionUniqueId: lastVisibleInteraction.uniqueId, ) ?? lastVisibleInteraction.uniqueId return CVScrollAction( action: .scrollTo( interactionId: renderedId, 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, interactionUniqueId: nil, 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. 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 { let interaction: TSInteraction let onScreenPercentage: CGFloat var sortId: UInt64 { interaction.sortId } var uniqueId: String { interaction.uniqueId } } public static func lastVisibleInteractionId(for thread: TSThread, tx: DBReadTransaction) -> 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) } } public func focusInitialVoiceoverElement() { let focusIndexPath: IndexPath if let _indexPath = indexPathOfUnreadMessagesIndicator { focusIndexPath = _indexPath } else if let lastVisibleInteraction = lastVisibleInteractionWithSneakyTransaction(), let _indexPath = indexPath(forInteractionUniqueId: lastVisibleInteraction.uniqueId) { focusIndexPath = _indexPath } else { return } if let cell = self.collectionView.cellForItem(at: focusIndexPath) as? CVCell, let componentView = cell.componentView { UIAccessibility.post( notification: .screenChanged, argument: componentView.rootView, ) } } public func focusVoiceoverElementAfterScroll() { if let scrolledMessageId = viewState.focusedMessageId, let scrolledMessageIndexPath = indexPath(forInteractionUniqueId: scrolledMessageId), let cell = self.collectionView.cellForItem(at: scrolledMessageIndexPath) as? CVCell, let componentView = cell.componentView { UIAccessibility.post( notification: .screenChanged, argument: componentView.rootView, ) viewState.focusedMessageId = nil } } private static func lastVisibleInteraction(for thread: TSThread, tx: DBReadTransaction) -> LastVisibleInteraction? { guard let lastVisibleInteraction = DependenciesBridge.shared.lastVisibleInteractionStore .lastVisibleInteraction(for: thread, tx: tx), let interaction = thread.firstInteraction(atOrAroundSortId: lastVisibleInteraction.sortId, transaction: tx) else { return nil } let onScreenPercentage = lastVisibleInteraction.onScreenPercentage return LastVisibleInteraction(interaction: interaction, onScreenPercentage: onScreenPercentage) } }