Tusker/Tusker/Screens/Utilities/DiffableTimelineLikeTableVi...

339 lines
12 KiB
Swift

//
// DiffableTimelineLikeTableViewController.swift
// Tusker
//
// Created by Shadowfacts on 6/18/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable, Item: Hashable>: 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(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, cellProvider: self.cellProvider)
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 viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
pruneOffscreenRows()
currentToast?.dismissToast(animated: false)
}
class func refreshCommandTitle() -> String {
return "Refresh"
}
private func pruneOffscreenRows() {
guard let lastVisibleRow = lastLastVisibleRow else {
return
}
var snapshot = dataSource.snapshot()
let lastVisibleRowSection = snapshot.sectionIdentifiers[lastVisibleRow.section]
let contentSections = snapshot.sectionIdentifiers.filter { timelineContentSections().contains($0) }
guard let lastVisibleContentSectionIndex = contentSections.lastIndex(of: lastVisibleRowSection) else {
return
}
if lastVisibleContentSectionIndex < contentSections.count - 1 {
// there are more content sections below the current last visible one
let sectionsToRemove = contentSections[lastVisibleContentSectionIndex...]
snapshot.deleteSections(Array(sectionsToRemove))
willRemoveItems(sectionsToRemove.flatMap(snapshot.itemIdentifiers(inSection:)))
} else if lastVisibleContentSectionIndex == contentSections.count - 1 {
let items = snapshot.itemIdentifiers(inSection: lastVisibleRowSection)
if lastVisibleRow.row < items.count - pageSize {
let itemsToRemove = Array(items.suffix(pageSize))
snapshot.deleteItems(itemsToRemove)
willRemoveItems(itemsToRemove)
} else {
return
}
} else {
return
}
dataSource.apply(snapshot, animatingDifferences: false)
}
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
loadInitialItems() { result in
DispatchQueue.main.async {
switch result {
case let .success(snapshot):
self.dataSource.apply(snapshot, animatingDifferences: false)
self.state = .loaded
case let .failure(.client(error)):
self.state = .unloaded
var config = ToastConfiguration(title: "Error Loading")
config.subtitle = error.localizedDescription
config.systemImageName = error.systemImageName
config.actionTitle = "Retry"
config.action = { [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 != .loadingOlder else { return }
state = .loadingOlder
loadOlderItems(currentSnapshot: dataSource.snapshot) { result in
DispatchQueue.main.async {
self.state = .loaded
switch result {
case let .success(snapshot):
self.dataSource.apply(snapshot, animatingDifferences: false)
case let .failure(.client(error)):
var config = ToastConfiguration(title: "Error Loading Older")
config.subtitle = error.localizedDescription
config.systemImageName = error.systemImageName
config.actionTitle = "Retry"
config.action = { [weak self] (toast) in
toast.dismissToast(animated: true)
self?.loadOlder()
}
self.showToast(configuration: config, animated: true)
default:
break
}
}
}
}
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() {
guard state != .loadingNewer else { 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)):
var config = ToastConfiguration(title: "Error Loading Newer")
config.subtitle = error.localizedDescription
config.systemImageName = error.systemImageName
config.actionTitle = "Retry"
config.action = { [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 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 {
}
fileprivate extension Client.Error {
var systemImageName: String {
switch self {
case .networkError(_):
return "wifi.exclamationmark"
default:
return "exclamationmark.triangle"
}
}
}