mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
7cb3a499b2
* ios: shooth chat switching * debug button * navigation timeout * fix scroll crash * fix merge * whitespace * wip * add spinner; extract load and nav logic * cleanup * direct chat button * cleanup * showLoadingProgress * reverse rename * rename * spinner layout * move all programmatic navigation to `openLoadChat` * remove access restriction * fix scroll on item added regression * print * fix page load regression * fix member sheet disappearing --------- Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
310 lines
11 KiB
Swift
310 lines
11 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
|
|
|
|
/// A List, which displays it's items in reverse order - from bottom to top
|
|
struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIViewControllerRepresentable {
|
|
let items: Array<Item>
|
|
|
|
@Binding var scrollState: ReverseListScrollModel<Item>.State
|
|
|
|
/// Closure, that returns user interface for a given item
|
|
let content: (Item) -> Content
|
|
|
|
let loadPage: () -> Void
|
|
|
|
func makeUIViewController(context: Context) -> Controller {
|
|
Controller(representer: self)
|
|
}
|
|
|
|
func updateUIViewController(_ controller: Controller, context: Context) {
|
|
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
|
|
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 {
|
|
controller.update(items: items)
|
|
}
|
|
}
|
|
|
|
/// Controller, which hosts SwiftUI cells
|
|
class Controller: UITableViewController {
|
|
private enum Section { case main }
|
|
private let representer: ReverseList
|
|
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
|
private var itemCount: Int = 0
|
|
private var bag = Set<AnyCancellable>()
|
|
|
|
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, Item>(
|
|
tableView: tableView
|
|
) { (tableView, indexPath, item) -> UITableViewCell? in
|
|
if indexPath.item > self.itemCount - 8, self.itemCount > 8 {
|
|
self.representer.loadPage()
|
|
}
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
|
|
if #available(iOS 16.0, *) {
|
|
cell.contentConfiguration = UIHostingConfiguration { self.representer.content(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(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
|
|
)
|
|
}
|
|
|
|
@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 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: Array<Item>) {
|
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
|
snapshot.appendSections([.main])
|
|
snapshot.appendItems(items)
|
|
dataSource.defaultRowAnimation = .none
|
|
dataSource.apply(
|
|
snapshot,
|
|
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1
|
|
)
|
|
itemCount = items.count
|
|
}
|
|
}
|
|
|
|
/// `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
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Manages ``ReverseList`` scrolling
|
|
class ReverseListScrollModel<Item: Identifiable>: ObservableObject {
|
|
/// Represents Scroll State of ``ReverseList``
|
|
enum State: Equatable {
|
|
enum Destination: Equatable {
|
|
case nextPage
|
|
case item(Item.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: Item.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
|
|
}
|
|
}
|