// // 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: UIViewControllerRepresentable { let items: Array @Binding var scrollState: ReverseListScrollModel.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! private var itemCount: Int = 0 private var bag = Set() 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.self, forCellReuseIdentifier: cellReuseId ) } // 3. Configure data source self.dataSource = UITableViewDiffableDataSource( 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 { 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) { var snapshot = NSDiffableDataSourceSnapshot() 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: UITableViewCell { private let hostingController = UIHostingController(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: 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( _ 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 } }