371 lines
14 KiB
Swift
371 lines
14 KiB
Swift
//
|
|
// 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<Section: DiffableTimelineLikeSection, Item: DiffableTimelineLikeItem>: EnhancedTableViewController, RefreshableViewController {
|
|
|
|
typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item>
|
|
typealias LoadResult = Result<Snapshot, LoadError>
|
|
|
|
private let pageSize = 20
|
|
|
|
private(set) var state = State.unloaded
|
|
private var lastLastVisibleRow: IndexPath?
|
|
private var currentLoadingIndicatorWorkItem: DispatchWorkItem?
|
|
|
|
private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
|
|
|
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 {
|
|
}
|