forked from shadowfacts/Tusker
Add DiffableTimelineLikeTableViewController
This commit is contained in:
parent
ebb0770198
commit
49572c1fec
|
@ -155,6 +155,7 @@
|
||||||
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */; };
|
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */; };
|
||||||
D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */; };
|
D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */; };
|
||||||
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
|
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
|
||||||
|
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */; };
|
||||||
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */; };
|
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */; };
|
||||||
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
|
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
|
||||||
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; };
|
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; };
|
||||||
|
@ -553,6 +554,7 @@
|
||||||
D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentView.swift; sourceTree = "<group>"; };
|
D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentView.swift; sourceTree = "<group>"; };
|
||||||
D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentViewController.swift; sourceTree = "<group>"; };
|
D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentViewController.swift; sourceTree = "<group>"; };
|
||||||
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; };
|
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; };
|
||||||
|
D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffableTimelineLikeTableViewController.swift; sourceTree = "<group>"; };
|
||||||
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundableViewController.swift; sourceTree = "<group>"; };
|
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundableViewController.swift; sourceTree = "<group>"; };
|
||||||
D65F612D23AE990C00F3CFD3 /* Embassy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Embassy.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
D65F612D23AE990C00F3CFD3 /* Embassy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Embassy.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
D65F613023AE99E000F3CFD3 /* Ambassador.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Ambassador.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
D65F613023AE99E000F3CFD3 /* Ambassador.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Ambassador.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
@ -1485,6 +1487,7 @@
|
||||||
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */,
|
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */,
|
||||||
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */,
|
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */,
|
||||||
D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */,
|
D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */,
|
||||||
|
D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */,
|
||||||
D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */,
|
D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */,
|
||||||
D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */,
|
D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */,
|
||||||
);
|
);
|
||||||
|
@ -2151,6 +2154,7 @@
|
||||||
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
|
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
|
||||||
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
|
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
|
||||||
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
|
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
|
||||||
|
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */,
|
||||||
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */,
|
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */,
|
||||||
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
||||||
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
|
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
|
||||||
|
|
|
@ -0,0 +1,268 @@
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
guard case let .success(snapshot) = result else {
|
||||||
|
self.state = .unloaded
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
self.state = .loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadInitial() {
|
||||||
|
state = .unloaded
|
||||||
|
loadInitial()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOlder() {
|
||||||
|
guard state != .loadingOlder else { return }
|
||||||
|
|
||||||
|
state = .loadingOlder
|
||||||
|
|
||||||
|
loadOlderItems(currentSnapshot: dataSource.snapshot()) { result in
|
||||||
|
guard case let .success(snapshot) = result else {
|
||||||
|
self.state = .loaded
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
self.state = .loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if indexPath.section == tableView.numberOfSections - 1,
|
||||||
|
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
|
||||||
|
|
||||||
|
let snapshot = dataSource.snapshot()
|
||||||
|
|
||||||
|
var item: Item? = nil
|
||||||
|
for section in timelineContentSections() {
|
||||||
|
if let first = snapshot.itemIdentifiers(inSection: section).first {
|
||||||
|
item = first
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadNewerItems(currentSnapshot: snapshot) { result in
|
||||||
|
guard case let .success(snapshot) = result else {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.refreshControl?.endRefreshing()
|
||||||
|
self.state = .loaded
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.refreshControl?.endRefreshing()
|
||||||
|
self.dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
self.state = .loaded
|
||||||
|
|
||||||
|
if let item = item,
|
||||||
|
let indexPath = self.dataSource.indexPath(for: item) {
|
||||||
|
// maintain the current position in the list (don't scroll to top)
|
||||||
|
self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: Snapshot, completion: @escaping (LoadResult) -> Void) {
|
||||||
|
fatalError("loadOlderItesm(completion:) must be implemented by subclasses")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadNewerItems(currentSnapshot: 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 client(Client.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DiffableTimelineLikeTableViewController: BackgroundableViewController {
|
||||||
|
func sceneDidEnterBackground() {
|
||||||
|
pruneOffscreenRows()
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,8 +9,8 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// A table view controller that manages common functionality between timeline-like UIs.
|
/// 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,
|
/// For example, this class handles loading new items when the user scrolls to the end,
|
||||||
// refreshing, and pruning offscreen rows automatically.
|
/// refreshing, and pruning offscreen rows automatically.
|
||||||
class TimelineLikeTableViewController<Item>: EnhancedTableViewController, RefreshableViewController {
|
class TimelineLikeTableViewController<Item>: EnhancedTableViewController, RefreshableViewController {
|
||||||
|
|
||||||
private(set) var loaded = false
|
private(set) var loaded = false
|
||||||
|
|
Loading…
Reference in New Issue