// // TimelineLikeTableViewController.swift // Tusker // // Created by Shadowfacts on 11/15/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import UIKit /// A table view controller that manages common functionality between timeline-like UIs. /// For example, this class handles loading new items when the user scrolls to the end, /// refreshing, and pruning offscreen rows automatically. class TimelineLikeTableViewController: EnhancedTableViewController, RefreshableViewController { private(set) var loaded = false var sections: [[Item]] = [] private let pageSize = 20 private var lastLastVisibleRow: IndexPath? init() { super.init(style: .plain) addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: Self.refreshCommandTitle())) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func item(for indexPath: IndexPath) -> Item { return sections[indexPath.section][indexPath.row] } override func viewDidLoad() { super.viewDidLoad() tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 140 #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 viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) pruneOffscreenRows() } func loadInitial() { guard !loaded else { return } // set loaded immediately so we don't trigger another request while the current one is running loaded = true loadInitialItems() { (items) in DispatchQueue.main.async { guard items.count > 0 else { // set loaded back to false so the next time the VC appears, we try to load again // todo: this should probably retry automatically self.loaded = false return } if self.sections.count < self.headerSectionsCount() { self.sections.insert(contentsOf: Array(repeating: [], count: self.headerSectionsCount() - self.sections.count), at: 0) } self.sections.append(items) self.tableView.reloadData() } } } func reloadInitialItems() { loaded = false sections = [] loadInitial() } func cellHeightChanged() { // causes the table view to recalculate the cell heights tableView.beginUpdates() tableView.endUpdates() } class func refreshCommandTitle() -> String { return "Refresh" } // todo: these three should use Result<[Item], Client.Error> so we can differentiate between failed requests and there actually being no results func loadInitialItems(completion: @escaping ([Item]) -> Void) { fatalError("loadInitialItems(completion:) must be implemented by subclasses") } func loadOlder(completion: @escaping ([Item]) -> Void) { fatalError("loadOlder(completion:) must be implemented by subclasses") } func loadNewer(completion: @escaping ([Item]) -> Void) { fatalError("loadNewer(completion:) must be implemented by subclasses") } func willRemoveRows(at indexPaths: [IndexPath]) { } func headerSectionsCount() -> Int { return 0 } private func pruneOffscreenRows() { guard let lastVisibleRow = lastLastVisibleRow, // never remove the last section sections.count - headerSectionsCount() > 1 else { return } let lastSectionIndex = sections.count - 1 if lastVisibleRow.section < lastSectionIndex { // if there is a section below the last visible one let sectionsToRemove = (lastVisibleRow.section + 1)...lastSectionIndex let indexPathsToRemove = sectionsToRemove.flatMap { (section) in sections[section].indices.map { (row) in IndexPath(row: row, section: section) } } willRemoveRows(at: indexPathsToRemove) UIView.performWithoutAnimation { tableView.deleteSections(IndexSet(sectionsToRemove), with: .none) } sections.removeSubrange(sectionsToRemove) } else if lastVisibleRow.section == lastSectionIndex { let lastSection = sections.last! let lastRowIndex = lastSection.count - 1 if lastVisibleRow.row < lastRowIndex - pageSize { // if there are more than pageSize rows in the current section below the last visible one let rowIndicesInLastSectionToRemove = (lastVisibleRow.row + pageSize).. Int { return sections.count } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return sections[section].count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { fatalError("tableView(_:cellForRowAt:) must be implemented by subclasses") } // 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 if indexPath.section == sections.count - 1, indexPath.row == sections[indexPath.section].count - 1 { loadOlder() { (newItems) in guard newItems.count > 0 else { return } DispatchQueue.main.async { let newRows = self.sections.last!.count..<(self.sections.last!.count + newItems.count) let newIndexPaths = newRows.map { IndexPath(row: $0, section: self.sections.count - 1) } self.sections[self.sections.count - 1].append(contentsOf: newItems) UIView.performWithoutAnimation { self.tableView.insertRows(at: newIndexPaths, with: .none) } } } } } 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() { loadNewer() { (newItems) in DispatchQueue.main.async { self.refreshControl?.endRefreshing() guard newItems.count > 0 else { return } let firstNonHeaderSection = self.headerSectionsCount() self.sections[firstNonHeaderSection].insert(contentsOf: newItems, at: 0) let newIndexPaths = (0..