// // DiffableTimelineLikeTableViewController.swift // Tusker // // Created by Shadowfacts on 6/18/21. // Copyright © 2021 Shadowfacts. All rights reserved. // import UIKit import Pachyderm protocol DiffableTimelineLikeSection: Hashable, CaseIterable { static var loadingIndicator: Self { get } } protocol DiffableTimelineLikeItem: Hashable { static var loadingIndicator: Self { get } } class DiffableTimelineLikeTableViewController: EnhancedTableViewController, RefreshableViewController { typealias Snapshot = NSDiffableDataSourceSnapshot typealias LoadResult = Result private let pageSize = 20 private(set) var state = State.unloaded private var lastLastVisibleRow: IndexPath? private var currentLoadingIndicatorWorkItem: DispatchWorkItem? private(set) var dataSource: UITableViewDiffableDataSource! init() { super.init(style: .plain) addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: Self.refreshCommandTitle())) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() dataSource = UITableViewDiffableDataSource(tableView: tableView) { [unowned self] (tableView, indexPath, item) in self.cellProvider(tableView, indexPath, item) } tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 140 tableView.register(LoadingTableViewCell.self, forCellReuseIdentifier: "loadingCell") #if !targetEnvironment(macCatalyst) self.refreshControl = UIRefreshControl() self.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged) #endif if let prefetchSource = self as? UITableViewDataSourcePrefetching { tableView.prefetchDataSource = prefetchSource } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) loadInitial() } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) pruneOffscreenRows() currentToast?.dismissToast(animated: false) } class func refreshCommandTitle() -> String { return "Refresh" } private func pruneOffscreenRows() { guard let lastVisibleRow = tableView.indexPathsForVisibleRows?.last else { return } var snapshot = dataSource.snapshot() let lastVisibleRowSection = snapshot.sectionIdentifiers[lastVisibleRow.section] let contentSections = snapshot.sectionIdentifiers.filter { timelineContentSections().contains($0) } let contentSectionIndices = contentSections.compactMap(snapshot.indexOfSection(_:)) guard let maxContentSectionIndex = contentSectionIndices.max() else { return } if lastVisibleRow.section < maxContentSectionIndex { return } else if lastVisibleRow.section == maxContentSectionIndex { let items = snapshot.itemIdentifiers(inSection: lastVisibleRowSection) let numberOfPagesToPrune = (items.count - lastVisibleRow.row - 1) / pageSize if numberOfPagesToPrune > 0 { let itemsToRemove = Array(items.suffix(numberOfPagesToPrune * pageSize)) snapshot.deleteItems(itemsToRemove) willRemoveItems(itemsToRemove) } else { return } } else { // unreachable return } dataSource.apply(snapshot, animatingDifferences: false) } private func showLoadingIndicatorDelayed() -> DispatchWorkItem { currentLoadingIndicatorWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in guard let self = self else { return } var snapshot = self.dataSource.snapshot() var changed = false if !snapshot.sectionIdentifiers.contains(.loadingIndicator) { snapshot.appendSections([.loadingIndicator]) changed = true } if changed || !snapshot.itemIdentifiers(inSection: .loadingIndicator).contains(.loadingIndicator) { snapshot.appendItems([.loadingIndicator], toSection: .loadingIndicator) changed = true } if changed { self.dataSource.apply(snapshot, animatingDifferences: false) } } currentLoadingIndicatorWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: workItem) return workItem } private func loadInitial() { guard state == .unloaded else { return } // set loaded immediately so we don't trigger another request while the current one is running state = .loadingInitial let showIndicator = showLoadingIndicatorDelayed() loadInitialItems() { result in DispatchQueue.main.async { showIndicator.cancel() switch result { case .success(var snapshot): if snapshot.sectionIdentifiers.contains(.loadingIndicator) { snapshot.deleteSections([.loadingIndicator]) } self.dataSource.apply(snapshot, animatingDifferences: false) self.state = .loaded case let .failure(.client(error)): self.state = .unloaded let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] (toast) in toast.dismissToast(animated: true) self?.loadInitial() } self.showToast(configuration: config, animated: true) default: self.state = .unloaded } } } } func reloadInitial() { state = .unloaded loadInitial() } func loadOlder() { guard state == .loaded else { return } state = .loadingOlder let showIndicator = showLoadingIndicatorDelayed() loadOlderItems(currentSnapshot: dataSource.snapshot) { result in DispatchQueue.main.async { self.state = .loaded showIndicator.cancel() switch result { case .success(var snapshot): if snapshot.sectionIdentifiers.contains(.loadingIndicator) { snapshot.deleteSections([.loadingIndicator]) } self.dataSource.apply(snapshot, animatingDifferences: false) case let .failure(.client(error)): let config = ToastConfiguration(from: error, with: "Error Loading Older", in: self) { [weak self] (toast) in toast.dismissToast(animated: true) self?.loadOlder() } self.showToast(configuration: config, animated: true) default: break } } } } @available(iOS, deprecated: 16.0) func cellHeightChanged() { // causes the table view to recalculate the cell heights tableView.beginUpdates() tableView.endUpdates() } // MARK: - UITableViewDelegate override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { // this assumes that indexPathsForVisibleRows is always in order lastLastVisibleRow = tableView.indexPathsForVisibleRows?.last let orderedContentSections = dataSource.snapshot().sectionIdentifiers.enumerated().filter { timelineContentSections().contains($0.element) } if let lastContentSection = orderedContentSections.last, indexPath.section == lastContentSection.offset, indexPath.row == tableView.numberOfRows(inSection: indexPath.section) - 1 { loadOlder() } } override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { return true } override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.leadingSwipeActionsConfiguration() } override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration() } // MARK: - RefreshableViewController func refresh() { // if we're unloaded, there's nothing "newer" to load // if we're performing some other operation, we don't want to step on its toes guard state == .loaded else { self.refreshControl?.endRefreshing() return } state = .loadingNewer var firstItem: Item? = nil let currentSnapshot: () -> Snapshot = { let snapshot = self.dataSource.snapshot() for section in self.timelineContentSections() { if snapshot.indexOfSection(section) != nil, let first = snapshot.itemIdentifiers(inSection: section).first { firstItem = first break } } return snapshot } loadNewerItems(currentSnapshot: currentSnapshot) { result in DispatchQueue.main.async { self.refreshControl?.endRefreshing() self.state = .loaded switch result { case let .success(snapshot): self.dataSource.apply(snapshot, animatingDifferences: false) if let firstItem = firstItem, let indexPath = self.dataSource.indexPath(for: firstItem) { // maintain the current position in the list (don't scroll to top) self.tableView.scrollToRow(at: indexPath, at: .top, animated: false) } case let .failure(.client(error)): let config = ToastConfiguration(from: error, with: "Error Loading Newer", in: self) { [weak self] (toast) in toast.dismissToast(animated: true) self?.refresh() } self.showToast(configuration: config, animated: true) case .failure(.allCaughtUp): var config = ToastConfiguration(title: "You're all caught up") config.edge = .top config.dismissAutomaticallyAfter = 2 config.action = { (toast) in toast.dismissToast(animated: true) } self.showToast(configuration: config, animated: true) default: break } } } } // MARK: - Subclass Methods func loadingIndicatorCell(indexPath: IndexPath) -> UITableViewCell? { let cell = tableView.dequeueReusableCell(withIdentifier: "loadingCell", for: indexPath) as! LoadingTableViewCell cell.indicator.startAnimating() return cell } func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? { fatalError("cellProvider(_:_:_:) must be implemented by subclasses") } func loadInitialItems(completion: @escaping (LoadResult) -> Void) { fatalError("loadInitialItems(completion:) must be implemented by subclasses") } func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) { fatalError("loadOlderItesm(completion:) must be implemented by subclasses") } func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) { fatalError("loadNewerItems(completion:) must be implemented by subclasses") } func timelineContentSections() -> Section.AllCases { return Section.allCases } func willRemoveItems(_ items: [Item]) { } } extension DiffableTimelineLikeTableViewController { enum State: Equatable { case unloaded case loadingInitial case loaded case loadingNewer case loadingOlder } } extension DiffableTimelineLikeTableViewController { enum LoadError: LocalizedError { case noClient case noOlder case noNewer case allCaughtUp case client(Client.Error) } } extension DiffableTimelineLikeTableViewController: BackgroundableViewController { func sceneDidEnterBackground() { pruneOffscreenRows() currentToast?.dismissToast(animated: false) } } extension DiffableTimelineLikeTableViewController: ToastableViewController { }