Files
simplex-chat/apps/ios/Shared/Views/Chat/ReverseList.swift
T

642 lines
28 KiB
Swift

//
// ReverseList.swift
// SimpleX (iOS)
//
// Created by Levitating Pineapple on 11/06/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import Combine
import SimpleXChat
/// A List, which displays it's items in reverse order - from bottom to top
struct ReverseList<Content: View>: UIViewControllerRepresentable {
var mergedItems: MergedItems
@Binding var revealedItems: Set<Int64>
@Binding var unreadCount: Int
@Binding var scrollState: ReverseListScrollModel.State
@Binding var loadingMoreItems: Bool
@Binding var allowLoadMoreItems: Bool
@Binding var ignoreLoadingRequests: [Int64]
let waitOnEndedScrolling = false
/// Closure, that returns user interface for a given item
/// Index, merged item
let content: (Int, MergedItem) -> Content
// unchecked, pagination, visibleItemIndexesNonReversed
let loadItems: (Bool, ChatPagination, @escaping @MainActor () -> ClosedRange<Int>) async -> Bool
func makeUIViewController(context: Context) -> Controller {
Controller(representer: self)
}
func updateUIViewController(_ controller: Controller, context: Context) {
controller.representer = self
if case let .scrollingTo(destination) = scrollState, !mergedItems.items.isEmpty, !controller.scrollToItemInProgress {
controller.view.layer.removeAllAnimations()
switch destination {
case let .item(id):
let row = mergedItems.indexInParentItems[id]
if let row {
logger.debug("LALAL SCROLLING TO \(row)")
controller.scroll(to: row, position: .bottom)
logger.debug("LALAL SCROLLING ENDED TO \(row)")
} else {
controller.scrollToItem(id)
}
case let .row(row):
controller.scroll(to: row, position: .bottom)
case .bottom:
logger.debug("LALAL WANT TO SCROLL BOTTOM \(self.loadingMoreItems)")
//DispatchQueue.main.async {
controller.scroll(to: 0, position: .top)
logger.debug("LALAL FINISHED SCROLL BOTTOM0")
//}
//logger.debug("LALAL FINISHED SCROLL BOTTOM1")
}
} else {
// when tableView is dragging and new items are added, scroll position cannot be set correctly
// so it's better to just wait until dragging ends
if waitOnEndedScrolling && controller.tableView.isDragging/* && !controller.tableView.isDecelerating*/ {
controller.runBlockOnEndDecelerating = {
controller.update()
}
} else {
controller.runBlockOnEndDecelerating = nil
controller.update()
}
}
}
/// Controller, which hosts SwiftUI cells
public class Controller: UITableViewController {
private enum Section { case main }
var representer: ReverseList
private var dataSource: UITableViewDiffableDataSource<Section, MergedItem>!
private let updateFloatingButtons = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>()
var runBlockOnEndDecelerating: (() -> Void)? = nil
private var scrollToRowOnAppear = 0
private var prevFirstVisible = -1
// it's set when .around call will be executed which requires to insert more items and skip scrolling
var scrollToItemInProgress = false
private var runBlockOnEndScrolling: (() -> Void)? = nil
private let useSmoothScrolling = false
init(representer: ReverseList) {
self.representer = representer
super.init(style: .plain)
// 1. Style
tableView = InvertedTableView()
tableView.separatorStyle = .none
tableView.transform = .verticalFlip
tableView.backgroundColor = .clear
// 2. Register cells
if #available(iOS 16.0, *) {
tableView.register(
UITableViewCell.self,
forCellReuseIdentifier: cellReuseId
)
} else {
tableView.register(
HostingCell<Content>.self,
forCellReuseIdentifier: cellReuseId
)
}
// 3. Configure data source
self.dataSource = UITableViewDiffableDataSource<Section, MergedItem>(
tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
if #available(iOS 16.0, *) {
cell.contentConfiguration = UIHostingConfiguration { self.representer.content(indexPath.item, item) }
.margins(.all, 0)
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
} else {
if let cell = cell as? HostingCell<Content> {
cell.set(content: self.representer.content(indexPath.item, item), parent: self)
} else {
fatalError("Unexpected Cell Type for: \(item)")
}
}
cell.transform = .verticalFlip
cell.selectionStyle = .none
cell.backgroundColor = .clear
return cell
}
// 4. External state changes will require manual layout updates
NotificationCenter.default
.addObserver(
self,
selector: #selector(updateLayout),
name: notificationName,
object: nil
)
updateFloatingButtons
.throttle(for: 0.2, scheduler: DispatchQueue.global(qos: .background), latest: true)
.sink {
let listState = DispatchQueue.main.sync(execute: { [weak self] in self?.getListState() }) ?? ListState()
ChatView.FloatingButtonModel.shared.updateOnListChange(representer.mergedItems, listState)
//self.preloadIfNeeded(listState)
}
.store(in: &bag)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
deinit { NotificationCenter.default.removeObserver(self) }
@objc private func updateLayout() {
if #available(iOS 16.0, *) {
tableView.setNeedsLayout()
tableView.layoutIfNeeded()
} else {
tableView.reloadData()
}
}
/// Hides keyboard, when user begins to scroll.
/// Equivalent to `.scrollDismissesKeyboard(.immediately)`
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
UIApplication.shared
.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil
)
NotificationCenter.default.post(name: .chatViewWillBeginScrolling, object: nil)
}
override public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
runBlockOnEndDecelerating?()
runBlockOnEndDecelerating = nil
}
/// depending on tableView layout phase conditions, it can already have known size or not. Not possible to correctly scroll to required
/// item if the size is unknown
func scrollToRowWhenKnowSize(_ row: Int) {
// logger.debug("LALAL WILL SCROLL TO \(self.scrollToRowOnAppear)")
if row > 0 && tableView.visibleSize.height > 0 {
// logger.debug("LALAL OFFSET before \(self.tableView.contentOffset.y), visible \(self.tableView.visibleSize.height) \(self.tableView.frame.height) \(self.view.frame.height) \(self.tableView.indexPathsForVisibleRows!)")
//tableView.setContentOffset(CGPointMake(0, tableView.contentOffset.y - tableView.visibleSize.height), animated: false)
tableView.scrollToRow(at: IndexPath(item: min(tableView.numberOfRows(inSection: 0) - 1, row), section: 0), at: .bottom, animated: false)
// Without this small scroll position is not correct pixel-to-pixel.
// Only needed when viewDidAppear has not been called yet because in there clipsToBounds is applied
if tableView.clipsToBounds {
tableView.setContentOffset(CGPointMake(0, tableView.contentOffset.y - 5), animated: false)
}
// logger.debug("LALAL OFFSET after \(self.tableView.contentOffset.y) \(self.tableView.indexPathsForVisibleRows!)")
scrollToRowOnAppear = 0
} else {
scrollToRowOnAppear = row
}
}
override public func viewIsAppearing(_ animated: Bool) {
super.viewIsAppearing(animated)
scrollToRowWhenKnowSize(scrollToRowOnAppear)
}
override func viewDidAppear(_ animated: Bool) {
tableView.clipsToBounds = false
parent?.viewIfLoaded?.clipsToBounds = false
}
/// Scrolls to Item at index path
func scroll(to index: Int, position: UITableView.ScrollPosition) {
var animated = false
if #available(iOS 16.0, *) {
animated = true
}
if tableView.numberOfRows(inSection: 0) > index && index >= 0 {
tableView.scrollToRow(
at: IndexPath(row: index, section: 0),
at: position,
animated: animated
)
} else {
logger.error("Scroll: index out of bounds of table view")
}
setScrollEndedListener()
}
func setScrollEndedListener() {
// wait until scrolling finishes so other calls will not interrupt it until then
let task = Task {
do {
try await Task.sleep(nanoseconds: 2000_000000)
// in case of listener will not be called
await MainActor.run {
representer.scrollState = .atDestination
logger.debug("LALAL SET DESTINATION0")
}
} catch {}
}
runBlockOnEndScrolling = {
task.cancel()
logger.debug("LALAL ENDED SCROLLING")
self.representer.scrollState = .atDestination
logger.debug("LALAL SET DESTINATION1")
self.runBlockOnEndScrolling = nil
}
}
func update() {
let prevSnapshot = dataSource.snapshot()
let wasCount = prevSnapshot.numberOfItems
let items = representer.mergedItems.items
let willBeCount = items.count
let c = 1
let insertedSeveralNewestItems = wasCount != 0 && willBeCount - wasCount == c && prevSnapshot.itemIdentifiers.first!.hashValue == self.representer.mergedItems.items[c].hashValue
logger.debug("LALAL WAS \(wasCount) will be \(self.representer.mergedItems.items.count)")
if items == prevSnapshot.itemIdentifiers {
logger.debug("LALAL SAME ITEMS, not rebuilding the tableview")
// update counters because they are static, unbound to specific chat and will become outdated if a new empty chat was open after non-empty one with unread messages
updateFloatingButtons.send()
return
}
var snapshot = NSDiffableDataSourceSnapshot<Section, MergedItem>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.defaultRowAnimation = .none
let wasContentHeight = tableView.contentSize.height
let wasOffset = tableView.contentOffset.y
let listState = getListState()
let wasFirstVisibleRow = listState?.firstVisibleItemIndex ?? 0//tableView.indexPathsForVisibleRows?.first?.row ?? 0
let wasFirstVisibleOffset = listState?.firstVisibleItemOffset ?? 0
let countDiff = max(0, snapshot.numberOfItems - prevSnapshot.numberOfItems)
// Sets content offset on initial load
if wasCount == 0 {
dataSource.apply(
snapshot,
animatingDifferences: insertedSeveralNewestItems
)
if let firstUnreadItem = snapshot.itemIdentifiers.lastIndex(where: { $0.hasUnread() }) {
scrollToRowWhenKnowSize(firstUnreadItem)
} else {
tableView.setContentOffset(
CGPoint(x: 0, y: -InvertedTableView.inset),
animated: false
)
}
} else if wasCount != snapshot.numberOfItems {
logger.debug("LALAL drag \(self.tableView.isDragging), decel \(self.tableView.isDecelerating)")
if useSmoothScrolling && tableView.isDecelerating {
tableView.panGestureRecognizer.isEnabled = false
tableView.beginUpdates()
dataSource.apply(
snapshot,
animatingDifferences: false
)
tableView.endUpdates()
tableView.panGestureRecognizer.isEnabled = true
} else {
// remember current translation
var translationToApply: CGPoint? = nil
if let superview = self.tableView.superview {
let t = self.tableView.panGestureRecognizer.translation(in: superview)
if t.y != 0 {
translationToApply = t
}
}
dataSource.apply(
snapshot,
animatingDifferences: false
)
logger.debug("LALAL WAS LISTSTATE \(listState?.firstVisibleItemIndex ?? -3) now \(self.getListState()?.firstVisibleItemIndex ?? -4)")
if listState?.firstVisibleItemIndex == getListState()?.firstVisibleItemIndex {
// added new items to top
} else {
self.tableView.setContentOffset(self.tableView.contentOffset, animated: false)
// added new items to bottom
// logger.debug("LALAL WAS HEIGHT \(wasContentHeight) now \(self.tableView.contentSize.height), offset was \(wasOffset), now \(self.tableView.contentOffset.y), will be \(self.tableView.contentOffset.y + (self.tableView.contentSize.height - wasContentHeight)), countDiff \(countDiff), wasVisibleRow \(wasFirstVisibleRow), wasFirstVisibleOffset \(wasFirstVisibleOffset)")
logger.debug("LALAL BEFORE SCROLLTOROW \(snapshot.numberOfItems - 1) \(countDiff) \(wasFirstVisibleRow) \(self.tableView.contentOffset.y) \(wasFirstVisibleOffset)")
getListState()
self.tableView.scrollToRow(
at: IndexPath(row: max(0, min(snapshot.numberOfItems - 1, countDiff + wasFirstVisibleRow)), section: 0),
at: .top,
animated: false
)
self.tableView.setContentOffset(
CGPoint(x: 0, y: self.tableView.contentOffset.y - wasFirstVisibleOffset),
animated: false
)
logger.debug("LALAL AFTER SCROLLTOROW")
let state = getListState()!
logger.debug("LALAL NOW FIRST VISIBLE \(state.firstVisibleItemIndex) \(state.firstVisibleItemOffset)")
if let t = translationToApply {
// self.tableView.panGestureRecognizer.setTranslation(CGPointMake(t.x, t.y), in: self.tableView.superview!)
let o = self.tableView.contentOffset
// UIView.animate(withDuration: 0.5) {
// self.tableView.setContentOffset(CGPointMake(o.x + t.x, o.y + t.y), animated: false)
// self.tableView.layoutIfNeeded()
// }
self.tableView.setContentOffset(CGPointMake(o.x + t.x, o.y + t.y), animated: true)
}
}
}
}
updateFloatingButtons.send()
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
updateFloatingButtons.send()
if let listState = self.getListState() {
self.preloadIfNeeded(listState)
}
}
override public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
if let onEnd = runBlockOnEndScrolling {
DispatchQueue.main.async {
onEnd()
}
}
}
func getListState() -> ListState? {
logger.debug("LALAL VISIBLE ROWS \((self.tableView.indexPathsForVisibleRows ?? []).map({ $0.row }))")
if let visibleRows = tableView.indexPathsForVisibleRows,
visibleRows.last?.row ?? 0 < representer.mergedItems.items.count {
let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset
let topItemDate: Date? =
if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) {
representer.mergedItems.items[lastVisible.item].oldest().item.meta.itemTs
} else {
nil
}
let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) })
let firstVisibleOffset: CGFloat? = if let row = firstVisible?.row {
offsetForRow(row)
} else { nil }
let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) })
logger.debug("LALAL LAST \(lastVisible?.item ?? -2)")
let bottomItemId: ChatItem.ID? =
if let firstVisible {
representer.mergedItems.items[firstVisible.row].newest().item.id
} else {
nil
}
return ListState(scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId, firstVisibleItemIndex: firstVisible?.row ?? 0, lastVisibleItemIndex: lastVisible?.row ?? 0, firstVisibleItemOffset: firstVisibleOffset ?? 0)
}
return nil
}
private func offsetForRow(_ row: Int) -> CGFloat? {
if let relativeFrame = tableView.superview?.convert(
tableView.rectForRow(at: IndexPath(row: row, section: 0)),
from: tableView
), relativeFrame.maxY > InvertedTableView.inset &&
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset {
// it is visible
let offset = tableView.frame.height - InvertedTableView.inset - relativeFrame.maxY
logger.debug("LALAL ROW \(row) minY \(relativeFrame.minY) maxY \(relativeFrame.maxY) table \(self.tableView.frame.height) inset \(InvertedTableView.inset)")
return offset
} else { return nil }
}
private func isVisible(indexPath: IndexPath) -> Bool {
if let relativeFrame = tableView.superview?.convert(
tableView.rectForRow(at: indexPath),
from: tableView
) {
relativeFrame.maxY > InvertedTableView.inset &&
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
} else { false }
}
func scrollToItem(_ itemId: Int64) {
if scrollToItemInProgress {
return
}
scrollToItemInProgress = true
Task {
logger.debug("LALAL SCROLL TO ITEM \(itemId) \(ItemsModel.shared.reversedChatItems.count)")
do {
var index = representer.mergedItems.indexInParentItems[itemId]
// setting it to 'loading' even if the item is loaded because in rare cases when the resulting item is near the top, scrolling to
// it will trigger loading more items and will scroll to incorrect position (because of trimming)
await MainActor.run {
representer.loadingMoreItems = true
}
logger.debug("LALAL SCROLL TO ITEM \(index ?? -2)")
if index == nil {
let pagination = ChatPagination.around(chatItemId: itemId, count: ChatPagination.PRELOAD_COUNT * 2)
let oldSize = ItemsModel.shared.reversedChatItems.count
_ = await self.representer.loadItems(true, pagination, { self.visibleItemIndexesNonReversed(Binding.constant(self.representer.mergedItems))})
var repeatsLeft = 50
while oldSize == ItemsModel.shared.reversedChatItems.count && repeatsLeft > 0 {
try await Task.sleep(nanoseconds: 20_000000)
repeatsLeft -= 1
logger.debug("LALAL SCROLL REPEATS \(repeatsLeft)")
}
index = representer.mergedItems.indexInParentItems[itemId]
}
logger.debug("LALAL SCROLL TO ITEM2 \(index ?? -3) \(ItemsModel.shared.reversedChatItems.count)")
if let index {
scroll(to: min(ItemsModel.shared.reversedChatItems.count - 1, index), position: .bottom)
} else {
await MainActor.run {
representer.scrollState = .atDestination
}
}
} catch {
logger.error("Error scrolling to item: \(error)")
}
await MainActor.run {
representer.loadingMoreItems = false
scrollToItemInProgress = false
}
}
}
func preloadIfNeeded(_ state: ListState) {
if representer.scrollState == .atDestination, prevFirstVisible != state.firstVisibleItemIndex {
logger.debug("LALAL LOADING before \(String(describing: self.representer.scrollState))")
prevFirstVisible = state.firstVisibleItemIndex
Task {
await preloadItems(self.representer.mergedItems, self.representer.allowLoadMoreItems, state, self.representer.$ignoreLoadingRequests) { pagination in
logger.debug("LALAL LOADING INSIDE")
let triedToLoad = await self.representer.loadItems(false, pagination, { self.visibleItemIndexesNonReversed(Binding.constant(self.representer.mergedItems)) })
return triedToLoad
}
}
}
}
}
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
/// Implemented as a `UITableViewCell` that wraps and manages a generic `UIHostingController`
private final class HostingCell<Hosted: View>: UITableViewCell {
private let hostingController = UIHostingController<Hosted?>(rootView: nil)
/// Updates content of the cell
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
func set(content: Hosted, parent: UIViewController) {
hostingController.view.backgroundColor = .clear
hostingController.rootView = content
if let hostingView = hostingController.view {
hostingView.invalidateIntrinsicContentSize()
if hostingController.parent != parent { parent.addChild(hostingController) }
if !contentView.subviews.contains(hostingController.view) {
contentView.addSubview(hostingController.view)
hostingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingView.leadingAnchor
.constraint(equalTo: contentView.leadingAnchor),
hostingView.trailingAnchor
.constraint(equalTo: contentView.trailingAnchor),
hostingView.topAnchor
.constraint(equalTo: contentView.topAnchor),
hostingView.bottomAnchor
.constraint(equalTo: contentView.bottomAnchor)
])
}
if hostingController.parent != parent { hostingController.didMove(toParent: parent) }
} else {
fatalError("Hosting View not loaded \(hostingController)")
}
}
override func prepareForReuse() {
super.prepareForReuse()
hostingController.rootView = nil
}
}
}
class ListState {
let scrollOffset: Double
let topItemDate: Date?
let bottomItemId: ChatItem.ID?
let firstVisibleItemIndex: Int
let lastVisibleItemIndex: Int
let firstVisibleItemOffset: CGFloat // can be negative or zero
init(scrollOffset: Double = 0, topItemDate: Date? = nil, bottomItemId: ChatItem.ID? = nil, firstVisibleItemIndex: Int = 0, lastVisibleItemIndex: Int = 0, firstVisibleItemOffset: CGFloat = 0) {
self.scrollOffset = scrollOffset
self.topItemDate = topItemDate
self.bottomItemId = bottomItemId
self.firstVisibleItemIndex = firstVisibleItemIndex
self.lastVisibleItemIndex = lastVisibleItemIndex
self.firstVisibleItemOffset = firstVisibleItemOffset
}
}
/// Manages ``ReverseList`` scrolling
class ReverseListScrollModel: ObservableObject {
/// Represents Scroll State of ``ReverseList``
enum State: Equatable {
enum Destination: Equatable {
case item(ChatItem.ID)
case row(Int)
case bottom
}
case scrollingTo(Destination)
case atDestination
}
@Published var state: State = .atDestination
func scrollToBottom() {
state = .scrollingTo(.bottom)
}
func scrollToItem(id: ChatItem.ID) {
state = .scrollingTo(.item(id))
}
func scrollToRow(row: Int) {
state = .scrollingTo(.row(row))
}
}
fileprivate let cellReuseId = "hostingCell"
fileprivate let notificationName = NSNotification.Name(rawValue: "reverseListNeedsLayout")
fileprivate extension CGAffineTransform {
/// Transform that vertically flips the view, preserving it's location
static let verticalFlip = CGAffineTransform(scaleX: 1, y: -1)
}
extension NotificationCenter {
static func postReverseListNeedsLayout() {
NotificationCenter.default.post(
name: notificationName,
object: nil
)
}
}
/// Disable animation on iOS 15
func withConditionalAnimation<Result>(
_ animation: Animation? = .default,
_ body: () throws -> Result
) rethrows -> Result {
if #available(iOS 16.0, *) {
try withAnimation(animation, body)
} else {
try body()
}
}
class InvertedTableView: UITableView {
static let inset = CGFloat(100)
static let insets = UIEdgeInsets(
top: inset,
left: .zero,
bottom: inset,
right: .zero
)
override var contentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior {
get { .never }
set { }
}
override var contentInset: UIEdgeInsets {
get { Self.insets }
set { }
}
override var adjustedContentInset: UIEdgeInsets {
Self.insets
}
// override var contentOffset: CGPoint {
// get { super.contentOffset }
// set {
// logger.debug("LALAL SET OFFSET \(newValue.y)")
// if newValue.y <= 0 {
// return
// }
// super.contentOffset = newValue
// }
// }
}