forked from shadowfacts/Tusker
246 lines
8.8 KiB
Swift
246 lines
8.8 KiB
Swift
//
|
|
// 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<Item>: 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 }
|
|
loaded = true
|
|
|
|
loadInitialItems() { (items) in
|
|
guard items.count > 0 else { return }
|
|
DispatchQueue.main.async {
|
|
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"
|
|
}
|
|
|
|
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)..<lastSection.count
|
|
|
|
let indexPathsToRemove = rowIndicesInLastSectionToRemove.map {
|
|
IndexPath(row: $0, section: lastSectionIndex)
|
|
}
|
|
willRemoveRows(at: indexPathsToRemove)
|
|
|
|
sections[lastSectionIndex].removeSubrange(rowIndicesInLastSectionToRemove)
|
|
|
|
UIView.performWithoutAnimation {
|
|
tableView.deleteRows(at: indexPathsToRemove, with: .none)
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - UITableViewDataSource
|
|
override func numberOfSections(in tableView: UITableView) -> 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..<newItems.count).map { IndexPath(row: $0, section: firstNonHeaderSection) }
|
|
UIView.performWithoutAnimation {
|
|
self.tableView.insertRows(at: newIndexPaths, with: .none)
|
|
}
|
|
|
|
// maintain the current position in the list (don't scroll to top)
|
|
self.tableView.scrollToRow(at: IndexPath(row: newItems.count, section: firstNonHeaderSection), at: .top, animated: false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension TimelineLikeTableViewController: BackgroundableViewController {
|
|
func sceneDidEnterBackground() {
|
|
pruneOffscreenRows()
|
|
}
|
|
}
|