element-x-ios/ElementX/Sources/Screens/RoomScreen/View/ListTableViewAdapter.swift

226 lines
7.8 KiB
Swift

//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import UIKit
class ListTableViewAdapter: NSObject, UITableViewDelegate {
private enum ContentOffsetDetails {
case topOffset(previousVisibleIndexPath: IndexPath, previousItemCount: Int)
case bottomOffset
}
private let topDetectionOffset: CGFloat
private let bottomDetectionOffset: CGFloat
private var contentOffsetObserverToken: NSKeyValueObservation?
private var boundsObserverToken: NSKeyValueObservation?
private var offsetDetails: ContentOffsetDetails?
private var draggingInitiated = false
private var isAnimatingKeyboardAppearance = false
private var previousFrame: CGRect = .zero
private(set) var tableView: UITableView?
let scrollViewDidRestPublisher = PassthroughSubject<Void, Never>()
let scrollViewTopVisiblePublisher = CurrentValueSubject<Bool, Never>(false)
let scrollViewBottomVisiblePublisher = CurrentValueSubject<Bool, Never>(false)
override init() {
topDetectionOffset = 0.0
bottomDetectionOffset = 0.0
}
init(tableView: UITableView, topDetectionOffset: CGFloat, bottomDetectionOffset: CGFloat) {
self.tableView = tableView
self.topDetectionOffset = topDetectionOffset
self.bottomDetectionOffset = bottomDetectionOffset
super.init()
tableView.clipsToBounds = true
tableView.keyboardDismissMode = .onDrag
registerContentOfffsetObserver()
registerBoundsObserver()
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow(notification:)), name: UIResponder.keyboardDidShowNotification, object: nil)
tableView.panGestureRecognizer.addTarget(self, action: #selector(handlePanGesture(_:)))
}
func saveCurrentOffset() {
guard let tableView = tableView,
tableView.numberOfSections > 0 else {
return
}
if computeIsBottomVisible() {
offsetDetails = .bottomOffset
} else if computeIsTopVisible() {
if let topIndexPath = tableView.indexPathsForVisibleRows?.first {
offsetDetails = .topOffset(previousVisibleIndexPath: topIndexPath,
previousItemCount: tableView.numberOfRows(inSection: 0))
}
}
}
func restoreSavedOffset() {
defer {
offsetDetails = nil
}
guard let tableView = tableView,
tableView.numberOfSections > 0 else {
return
}
let currentItemCount = tableView.numberOfRows(inSection: 0)
switch offsetDetails {
case .bottomOffset:
tableView.scrollToRow(at: .init(row: max(0, currentItemCount - 1), section: 0), at: .bottom, animated: false)
case .topOffset(let indexPath, let previousItemCount):
let row = indexPath.row + max(0, currentItemCount - previousItemCount)
if row < currentItemCount {
tableView.scrollToRow(at: .init(row: row, section: 0), at: .top, animated: false)
}
case .none:
break
}
}
var isTracking: Bool {
tableView?.isTracking == true
}
var isDecelerating: Bool {
tableView?.isDecelerating == true
}
func scrollToBottom(animated: Bool = false) {
guard let tableView = tableView,
tableView.numberOfSections > 0 else {
return
}
let currentItemCount = tableView.numberOfRows(inSection: 0)
guard currentItemCount > 1 else {
return
}
tableView.scrollToRow(at: .init(row: currentItemCount - 1, section: 0), at: .bottom, animated: animated)
}
// MARK: - Private
private func registerContentOfffsetObserver() {
// Don't attempt stealing the UITableView delegate away from the List.
// Doing so results in undefined behavior e.g. context menus not working
contentOffsetObserverToken = tableView?.observe(\.contentOffset, options: .new, changeHandler: { [weak self] _, _ in
self?.handleScrollViewScroll()
})
}
private func deregisterContentOffsetObserver() {
contentOffsetObserverToken?.invalidate()
}
private func registerBoundsObserver() {
boundsObserverToken = tableView?.observe(\.frame, options: .new, changeHandler: { [weak self] tableView, _ in
self?.previousFrame = tableView.frame
self?.handleScrollViewScroll()
})
}
private func deregisterBoundsObserver() {
boundsObserverToken?.invalidate()
}
@objc private func keyboardWillShow(notification: NSNotification) {
isAnimatingKeyboardAppearance = true
}
@objc private func keyboardDidShow(notification: NSNotification) {
isAnimatingKeyboardAppearance = false
}
private func handleScrollViewScroll() {
guard let tableView = tableView else {
return
}
let hasScrolledBecauseOfFrameChange = (previousFrame != tableView.frame)
let shouldPinToBottom = scrollViewBottomVisiblePublisher.value && (isAnimatingKeyboardAppearance || hasScrolledBecauseOfFrameChange)
if shouldPinToBottom {
deregisterContentOffsetObserver()
scrollToBottom()
DispatchQueue.main.async {
self.registerContentOfffsetObserver()
}
return
}
let isTopVisible = computeIsTopVisible()
if isTopVisible != scrollViewTopVisiblePublisher.value {
scrollViewTopVisiblePublisher.send(isTopVisible)
}
let isBottomVisible = computeIsBottomVisible()
if isBottomVisible != scrollViewBottomVisiblePublisher.value {
scrollViewBottomVisiblePublisher.send(isBottomVisible)
}
if !draggingInitiated, tableView.isDragging {
draggingInitiated = true
} else if draggingInitiated, !tableView.isDragging {
draggingInitiated = false
scrollViewDidRestPublisher.send(())
}
}
@objc private func handlePanGesture(_ sender: UIPanGestureRecognizer) {
guard let tableView = tableView,
sender.state == .ended,
draggingInitiated == true,
!tableView.isDecelerating else {
return
}
draggingInitiated = false
scrollViewDidRestPublisher.send(())
}
private func computeIsTopVisible() -> Bool {
guard let scrollView = tableView else {
return false
}
return (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) <= topDetectionOffset
}
private func computeIsBottomVisible() -> Bool {
guard let scrollView = tableView else {
return false
}
return (scrollView.contentOffset.y + bottomDetectionOffset) >= (scrollView.contentSize.height - scrollView.frame.size.height)
}
}