SimpleX-Chat/apps/ios/Shared/Views/Chat/ReverseList.swift

541 lines
23 KiB
Swift
Raw Normal View History

//
// 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 {
let items: Array<ChatItem>
2024-12-19 10:04:04 -08:00
@Binding var mergedItems: MergedItems
2024-12-18 08:27:11 -08:00
@Binding var revealedItems: Set<Int64>
@Binding var unreadCount: Int
@Binding var scrollState: ReverseListScrollModel.State
2024-12-26 07:41:28 -08:00
@State private var itemsUpdaterTask: Task<Void, Never>? = nil
/// Closure, that returns user interface for a given item
2024-12-18 08:27:11 -08:00
/// Index, merged item
let content: (Int, MergedItem) -> Content
2024-12-18 08:27:11 -08:00
// pagination, visibleItemIndexesNonReversed
let loadItems: (ChatPagination, @escaping () -> ClosedRange<Int>) -> Void
func makeUIViewController(context: Context) -> Controller {
Controller(representer: self)
}
func updateUIViewController(_ controller: Controller, context: Context) {
controller.representer = self
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
controller.view.layer.removeAllAnimations()
switch destination {
case .nextPage:
controller.scrollToNextPage()
case let .item(id):
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
case .bottom:
controller.scroll(to: 0, position: .top)
}
} else {
2024-12-26 07:41:28 -08:00
itemsUpdaterTask?.cancel()
// 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
2024-12-26 08:28:17 -08:00
if false && controller.tableView.isDragging {
2024-12-26 07:41:28 -08:00
DispatchQueue.main.async {
itemsUpdaterTask = Task {
while controller.tableView.isDragging {
do {
try await Task.sleep(nanoseconds: 100_000000)
} catch {
return
}
}
await MainActor.run {
controller.update(items: items)
}
}
}
} else {
controller.update(items: items)
}
}
}
/// Controller, which hosts SwiftUI cells
public class Controller: UITableViewController {
private enum Section { case main }
var representer: ReverseList
2024-12-26 07:41:28 -08:00
// Here Int means hash of the ChatItem that is inside MergedItem.newest().item.hashValue.
// Putting MergedItem here directly prevents UITableViewDiffableDataSource to make partial updates
// which looks like UITableView scrolls to bottom on insert values to bottom instead of
// remains in the same scroll position
2024-12-26 08:28:17 -08:00
private var dataSource: UITableViewDiffableDataSource<Section, BoxedValue2<MergedItem>>!
2024-12-18 08:27:11 -08:00
var itemCount: Int {
get {
2024-12-19 10:04:04 -08:00
representer.mergedItems.items.count
2024-12-18 08:27:11 -08:00
}
}
2024-12-26 07:41:28 -08:00
private var itemsInPrevSnapshot: Dictionary<Int, MergedItem> = [:]
private let updateFloatingButtons = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>()
2024-12-20 06:55:34 -08:00
private var scrollToRowOnAppear = 0
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
2024-12-26 08:28:17 -08:00
self.dataSource = UITableViewDiffableDataSource<Section, BoxedValue2<MergedItem>>(
tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in
if indexPath.item > self.itemCount - 8 {
2024-12-18 08:27:11 -08:00
logger.debug("LALAL ITEM \(indexPath.item)")
let pagination = ChatPagination.last(count: 0)
2024-12-19 10:04:04 -08:00
self.representer.loadItems(pagination, { self.visibleItemIndexesNonReversed(Binding.constant(self.representer.mergedItems)) })
}
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
if #available(iOS 16.0, *) {
2024-12-26 08:28:17 -08:00
cell.contentConfiguration = UIHostingConfiguration { self.representer.content(indexPath.item, item.boxedValue) }
.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> {
2024-12-26 08:28:17 -08:00
cell.set(content: self.representer.content(indexPath.item, item.boxedValue), parent: self)
} else {
fatalError("Unexpected Cell Type for: \(item)")
}
}
cell.transform = .verticalFlip
cell.selectionStyle = .none
ios: chat themes and wallpapers (#4376) * ios: wallpapers (#4304) * ios: wallpapers * theme selection * applied theme colors and preset wallpaper * more places with background * one more * accent color * defaults * rename * background * no change to cell color * unneeded * changes * no global tint * defaults * removed unneeded class * for merging * ios: wallpapers types (#4325) * types and api * divided types per target * creating directory for wallpapers * creating wallpaper dir at launch * ios: wallpapers appearance (#4335) * appearance * changes * refactor * scale * lambda to function --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> * ios: wallpapers user/chat overrides (#4345) * ios: wallpapers user/chat overrides * chat overrides * color picker updates colors correctly * fix state update * labels * background for light theme * small optimization * removed commented code * ios: enhancements to wallpapers (#4361) * ios: enhancements to wallpapers * colors for background * ios: wallpapers import/export (#4362) * ios: wallpapers import/export * comment * ios: wallpapers theme updates (#4365) * ios: wallpapers theme updates * group member background * colors * profile picture colors * unneeded * optimizations, images, state fixes * fixes * no editing of title color * rename Menus and alerts, refactor * tint applying fix * fixes * migration of accent and themes * fix updating system theme * migration changes * limiting color range * ios: wallpapers rename enum (#4384) * ios: wallpapers rename enum2 (#4385) * ios: wallpapers rename enum2 * change * colors were commented * fix build and look --------- Co-authored-by: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com>
2024-07-03 22:42:13 +01:00
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 {
if let listState = DispatchQueue.main.sync(execute: { [weak self] in self?.getListState() }) {
ChatView.FloatingButtonModel.shared.updateOnListChange(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)
}
2024-12-20 06:55:34 -08:00
/// 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 up
func scrollToNextPage() {
tableView.setContentOffset(
CGPoint(
x: tableView.contentOffset.x,
y: tableView.contentOffset.y + tableView.bounds.height
),
animated: true
)
Task { representer.scrollState = .atDestination }
}
/// Scrolls to Item at index path
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
func scroll(to index: Int?, position: UITableView.ScrollPosition) {
var animated = false
if #available(iOS 16.0, *) {
animated = true
}
if let index, tableView.numberOfRows(inSection: 0) != 0 {
tableView.scrollToRow(
at: IndexPath(row: index, section: 0),
at: position,
animated: animated
)
} else {
tableView.setContentOffset(
CGPoint(x: .zero, y: -InvertedTableView.inset),
animated: animated
)
}
Task { representer.scrollState = .atDestination }
}
func update(items: [ChatItem]) {
2024-12-20 06:55:34 -08:00
var prevSnapshot = dataSource.snapshot()
let wasCount = prevSnapshot.numberOfItems
2024-12-26 07:41:28 -08:00
let insertedOneNewestItem = wasCount != 0 && representer.mergedItems.items.count - wasCount == 1 && prevSnapshot.itemIdentifiers.first!.hashValue == self.representer.mergedItems.items[1].hashValue
2024-12-19 10:04:04 -08:00
logger.debug("LALAL WAS \(wasCount) will be \(self.representer.mergedItems.items.count)")
//self.representer.mergedItems = MergedItems.create(items, representer.unreadCount, representer.revealedItems, ItemsModel.shared.chatState)
2024-12-26 08:28:17 -08:00
let snapshot: NSDiffableDataSourceSnapshot<Section, BoxedValue2<MergedItem>>
2024-12-26 07:41:28 -08:00
let itemsInCurrentSnapshot: Dictionary<Int, MergedItem>
2024-12-20 06:55:34 -08:00
if insertedOneNewestItem {
2024-12-26 08:28:17 -08:00
prevSnapshot.insertItems([BoxedValue2(representer.mergedItems.items.first!)], beforeItem: prevSnapshot.itemIdentifiers.first!)
2024-12-26 07:41:28 -08:00
var new = itemsInPrevSnapshot
2024-12-26 08:28:17 -08:00
//new[representer.mergedItems.items.first!.hashValue] = representer.mergedItems.items.first!
2024-12-26 07:41:28 -08:00
itemsInCurrentSnapshot = new
2024-12-20 06:55:34 -08:00
snapshot = prevSnapshot
} else {
2024-12-26 07:41:28 -08:00
var new: Dictionary<Int, MergedItem> = [:]
2024-12-26 08:28:17 -08:00
var snap = NSDiffableDataSourceSnapshot<Section, BoxedValue2<MergedItem>>()
2024-12-20 06:55:34 -08:00
snap.appendSections([.main])
2024-12-26 07:41:28 -08:00
snap.appendItems(representer.mergedItems.items.map({ merged in
new[merged.hashValue] = merged
2024-12-26 08:28:17 -08:00
return BoxedValue2(merged)//.hashValue
2024-12-26 07:41:28 -08:00
}))
itemsInCurrentSnapshot = new
2024-12-20 06:55:34 -08:00
2024-12-26 07:41:28 -08:00
if (wasCount == 101 && self.representer.mergedItems.items.count == 101) || snap.itemIdentifiers == prevSnapshot.itemIdentifiers {
2024-12-20 06:55:34 -08:00
logger.debug("LALAL SAME ITEMS, not rebuilding the tableview")
return
}
snapshot = snap
}
dataSource.defaultRowAnimation = .none
2024-12-20 06:55:34 -08:00
let wasContentHeight = tableView.contentSize.height
let wasOffset = tableView.contentOffset.y
2024-12-26 07:41:28 -08:00
let listState = getListState()
let wasFirstVisibleRow = listState?.firstVisibleItemIndex ?? 0//tableView.indexPathsForVisibleRows?.first?.row ?? 0
let wasFirstVisibleOffset = listState?.firstVisibleItemOffset ?? 0
2024-12-20 06:55:34 -08:00
let countDiff = max(0, snapshot.numberOfItems - prevSnapshot.numberOfItems)
// Sets content offset on initial load
2024-12-18 08:27:11 -08:00
if wasCount == 0 {
2024-12-26 07:41:28 -08:00
itemsInPrevSnapshot = itemsInCurrentSnapshot
2024-12-20 06:55:34 -08:00
dataSource.apply(
snapshot,
animatingDifferences: insertedOneNewestItem
)
2024-12-26 08:28:17 -08:00
if let firstUnreadItem = snapshot.itemIdentifiers.lastIndex(where: { item in item.boxedValue.hasUnread() }) {
2024-12-20 06:55:34 -08:00
scrollToRowWhenKnowSize(firstUnreadItem)
} else {
tableView.setContentOffset(
CGPoint(x: 0, y: -InvertedTableView.inset),
animated: false
)
}
} else if wasCount != snapshot.numberOfItems {
2024-12-26 07:41:28 -08:00
logger.debug("LALAL drag \(self.tableView.isDragging), decel \(self.tableView.isDecelerating)")
// logger.debug("LALAL drag2 \(self.tableView.isDragging), decel \(self.tableView.isDecelerating)")
if tableView.isDecelerating {
itemsInPrevSnapshot = itemsInCurrentSnapshot
tableView.beginUpdates()
dataSource.apply(
snapshot,
animatingDifferences: false
)
tableView.endUpdates()
} else {
itemsInPrevSnapshot = itemsInCurrentSnapshot
dataSource.apply(
snapshot,
animatingDifferences: false
)
if snapshot.itemIdentifiers[0] == prevSnapshot.itemIdentifiers[0] {
// added new items to top
2024-12-20 06:55:34 -08:00
2024-12-26 07:41:28 -08:00
} else {
// 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)")
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
)
let state = getListState()!
logger.debug("LALAL NOW FIRST VISIBLE \(state.firstVisibleItemIndex) \(state.firstVisibleItemOffset)")
}
}
}
updateFloatingButtons.send()
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
updateFloatingButtons.send()
}
func getListState() -> ListState? {
if let visibleRows = tableView.indexPathsForVisibleRows,
2024-12-19 10:04:04 -08:00
visibleRows.last?.item ?? 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) }) {
2024-12-19 10:04:04 -08:00
representer.mergedItems.items[lastVisible.item].oldest().item.meta.itemTs
} else {
nil
}
let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) })
2024-12-26 07:41:28 -08:00
let firstVisibleOffset: CGFloat? = if let row = firstVisible?.row {
offsetForRow(row)
} else { nil }
let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) })
let bottomItemId: ChatItem.ID? =
if let firstVisible {
2024-12-19 10:04:04 -08:00
representer.mergedItems.items[firstVisible.item].newest().item.id
} else {
nil
}
2024-12-26 07:41:28 -08:00
return ListState(scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId, firstVisibleItemIndex: firstVisible?.item ?? 0, lastVisibleItemIndex: lastVisible?.item ?? 0, firstVisibleItemOffset: firstVisibleOffset ?? 0)
}
return nil
}
2024-12-26 07:41:28 -08:00
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 }
}
}
/// `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
2024-12-26 07:41:28 -08:00
let firstVisibleItemOffset: CGFloat // can be negative or zero
2024-12-26 07:41:28 -08:00
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
2024-12-26 07:41:28 -08:00
self.firstVisibleItemOffset = firstVisibleItemOffset
}
}
/// Manages ``ReverseList`` scrolling
class ReverseListScrollModel: ObservableObject {
/// Represents Scroll State of ``ReverseList``
enum State: Equatable {
enum Destination: Equatable {
case nextPage
case item(ChatItem.ID)
case bottom
}
case scrollingTo(Destination)
case atDestination
}
@Published var state: State = .atDestination
func scrollToNextPage() {
state = .scrollingTo(.nextPage)
}
func scrollToBottom() {
state = .scrollingTo(.bottom)
}
func scrollToItem(id: ChatItem.ID) {
state = .scrollingTo(.item(id))
}
}
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
}
}