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

300 lines
11 KiB
Swift

//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import Foundation
public import SignalServiceKit
extension ConversationViewController {
fileprivate var scrollDownButton: ConversationScrollButton { viewState.scrollDownButton }
fileprivate var scrollToNextMentionButton: ConversationScrollButton { viewState.scrollToNextMentionButton }
fileprivate var isHidingScrollDownButton: Bool {
get { viewState.isHidingScrollDownButton }
set { viewState.isHidingScrollDownButton = newValue }
}
fileprivate var isHidingScrollToNextMentionButton: Bool {
get { viewState.isHidingScrollToNextMentionButton }
set { viewState.isHidingScrollToNextMentionButton = newValue }
}
public var scrollUpdateTimer: Timer? {
get { viewState.scrollUpdateTimer }
set { viewState.scrollUpdateTimer = newValue }
}
public var isWaitingForDeceleration: Bool {
get { viewState.isWaitingForDeceleration }
set { viewState.isWaitingForDeceleration = newValue }
}
public var userHasScrolled: Bool {
get { viewState.userHasScrolled }
set {
guard viewState.userHasScrolled != newValue else {
return
}
viewState.userHasScrolled = newValue
ensureBannerState()
}
}
// MARK: -
public func configureScrollDownButtons() {
AssertIsOnMainThread()
guard hasAppearedAndHasAppliedFirstLoad else {
scrollDownButton.isHidden = true
scrollToNextMentionButton.isHidden = true
return
}
let scrollSpaceToBottom = (safeContentHeight + collectionView.contentInset.bottom
- (collectionView.contentOffset.y + collectionView.frame.height))
let pageHeight = (collectionView.frame.height
- (collectionView.contentInset.top + collectionView.contentInset.bottom))
let isScrolledUpOnePage = scrollSpaceToBottom > pageHeight * 1.0
let hasLaterMessageOffscreen = (lastSortIdInLoadedWindow > lastVisibleSortId) || canLoadNewerItems
let scrollDownWasHidden = isHidingScrollDownButton || scrollDownButton.isHidden
var scrollDownIsHidden = scrollDownWasHidden
let scrollToNextMentionWasHidden = isHidingScrollToNextMentionButton || scrollToNextMentionButton.isHidden
var scrollToNextMentionIsHidden = scrollToNextMentionWasHidden
if viewState.inProgressVoiceMessage?.isRecording == true {
scrollDownIsHidden = true
scrollToNextMentionIsHidden = true
} else if isInPreviewPlatter {
scrollDownIsHidden = true
scrollToNextMentionIsHidden = true
} else {
let shouldScrollDownAppear = isScrolledUpOnePage || hasLaterMessageOffscreen
scrollDownIsHidden = !shouldScrollDownAppear
let shouldScrollToMentionAppear = shouldScrollDownAppear && !conversationViewModel.unreadMentionMessageIds.isEmpty
scrollToNextMentionIsHidden = !shouldScrollToMentionAppear
}
self.scrollDownButton.unreadCount = threadViewModel.unreadCount
self.scrollToNextMentionButton.unreadCount = UInt(conversationViewModel.unreadMentionMessageIds.count)
let scrollDownVisibilityDidChange = scrollDownIsHidden != scrollDownWasHidden
let scrollToNextMentionVisibilityDidChange = scrollToNextMentionIsHidden != scrollToNextMentionWasHidden
let shouldAnimateChanges = self.hasAppearedAndHasAppliedFirstLoad
guard scrollDownVisibilityDidChange || scrollToNextMentionVisibilityDidChange else {
return
}
if scrollDownVisibilityDidChange {
self.scrollDownButton.isHidden = false
self.isHidingScrollDownButton = scrollDownIsHidden
scrollDownButton.layer.removeAllAnimations()
}
if scrollToNextMentionVisibilityDidChange {
self.scrollToNextMentionButton.isHidden = false
self.isHidingScrollToNextMentionButton = scrollToNextMentionIsHidden
scrollToNextMentionButton.layer.removeAllAnimations()
}
let alphaBlock = {
if scrollDownVisibilityDidChange {
self.scrollDownButton.alpha = scrollDownIsHidden ? 0 : 1
}
if scrollToNextMentionVisibilityDidChange {
self.scrollToNextMentionButton.alpha = scrollToNextMentionIsHidden ? 0 : 1
}
}
let completionBlock = {
if scrollDownVisibilityDidChange {
self.scrollDownButton.isHidden = scrollDownIsHidden
self.isHidingScrollDownButton = false
}
if scrollToNextMentionVisibilityDidChange {
self.scrollToNextMentionButton.isHidden = scrollToNextMentionIsHidden
self.isHidingScrollToNextMentionButton = false
}
}
scrollDownButton.layer.removeAllAnimations()
scrollToNextMentionButton.layer.removeAllAnimations()
if shouldAnimateChanges {
UIView.animate(withDuration: 0.2,
animations: alphaBlock) { finished in
if finished {
completionBlock()
}
}
} else {
alphaBlock()
completionBlock()
}
}
}
// MARK: -
extension ConversationViewController: UIScrollViewDelegate {
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
AssertIsOnMainThread()
// Constantly try to update the lastKnownDistanceFromBottom.
updateLastKnownDistanceFromBottom()
configureScrollDownButtons()
scheduleScrollUpdateTimer()
updateScrollingContent()
updateContextMenuInteractionIfNeeded()
}
private func scheduleScrollUpdateTimer() {
AssertIsOnMainThread()
guard self.scrollUpdateTimer == nil else {
return
}
// We need to manually schedule this timer using NSRunLoopCommonModes
// or it won't fire during scrolling.
let scrollUpdateTimer = Timer(timeInterval: 0.1, repeats: false) { [weak self] _ in
self?.scrollUpdateTimerDidFire()
}
self.scrollUpdateTimer = scrollUpdateTimer
RunLoop.main.add(scrollUpdateTimer, forMode: .common)
}
private func updateContextMenuInteractionIfNeeded() {
if let contextMenuInteraction = collectionViewActiveContextMenuInteraction {
contextMenuInteraction.cancelPresentationGesture()
}
}
private func scrollUpdateTimerDidFire() {
AssertIsOnMainThread()
scrollUpdateTimer?.invalidate()
scrollUpdateTimer = nil
guard viewHasEverAppeared else {
return
}
_ = autoLoadMoreIfNecessary()
if !isUserScrolling, !isWaitingForDeceleration {
saveLastVisibleSortIdAndOnScreenPercentage()
}
}
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
AssertIsOnMainThread()
self.userHasScrolled = true
self.isUserScrolling = true
scrollingAnimationDidStart()
}
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate: Bool) {
AssertIsOnMainThread()
if !willDecelerate {
scrollingAnimationDidComplete()
}
if !isUserScrolling {
return
}
self.isUserScrolling = false
if willDecelerate {
self.isWaitingForDeceleration = willDecelerate
} else {
scheduleScrollUpdateTimer()
}
}
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
AssertIsOnMainThread()
scrollingAnimationDidComplete()
if !isWaitingForDeceleration {
return
}
self.isWaitingForDeceleration = false
scheduleScrollUpdateTimer()
}
public func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
AssertIsOnMainThread()
// If the user taps on the status bar, the UIScrollView tries to perform
// a "scroll to top" animation that swings _past_ the top of the scroll
// view content, then bounces back to settle at zero. This is likely
// to trigger a "load older" load which can land before the animation
// settles. If so, the animation will overwrite the contentOffset,
// breaking scroll continuity and probably triggering another "load older"
// load. So there's also a risk of a load loop.
//
// To avoid this, we use a simple animation to "scroll to top" unless
// we know its safe to use the default animation, e.g. when there's no
// older content to load.
if canLoadOlderItems {
let newContentOffset = CGPoint(x: 0, y: 0)
collectionView.setContentOffset(newContentOffset, animated: true)
return false
} else {
scrollingAnimationDidStart()
return true
}
}
public func scrollViewDidScrollToTop(_ scrollView: UIScrollView) {
AssertIsOnMainThread()
scrollingAnimationDidComplete()
}
public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
AssertIsOnMainThread()
scrollingAnimationDidComplete()
}
}
// MARK: - Scroll Down Button
extension ConversationViewController {
public func createConversationScrollButtons() {
AssertIsOnMainThread()
scrollDownButton.addTarget(self, action: #selector(scrollDownButtonTapped), for: .touchUpInside)
scrollDownButton.isHidden = true
scrollDownButton.alpha = 0
view.addSubview(scrollDownButton)
scrollDownButton.autoSetDimension(.width, toSize: ConversationScrollButton.buttonSize)
scrollDownButton.accessibilityIdentifier = "scrollDownButton"
scrollDownButton.autoPinEdge(.bottom, to: .top, of: bottomBar, withOffset: -16)
scrollDownButton.autoPinEdge(toSuperviewSafeArea: .trailing)
scrollToNextMentionButton.addTarget(self, action: #selector(scrollToNextMentionButtonTapped), for: .touchUpInside)
scrollToNextMentionButton.isHidden = true
scrollToNextMentionButton.alpha = 0
view.addSubview(scrollToNextMentionButton)
scrollToNextMentionButton.autoSetDimension(.width, toSize: ConversationScrollButton.buttonSize)
scrollToNextMentionButton.accessibilityIdentifier = "scrollToNextMentionButton"
scrollToNextMentionButton.autoPinEdge(.bottom, to: .top, of: scrollDownButton, withOffset: -10)
scrollToNextMentionButton.autoPinEdge(toSuperviewSafeArea: .trailing)
}
}