diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 487bf316..dd4d1c08 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -155,6 +155,7 @@ D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */; }; D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DEF246B867E000F9538 /* GifvAttachmentViewController.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 */; }; D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.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 = ""; }; D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentViewController.swift; sourceTree = ""; }; D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = ""; }; + D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffableTimelineLikeTableViewController.swift; sourceTree = ""; }; D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundableViewController.swift; sourceTree = ""; }; 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; }; @@ -1485,6 +1487,7 @@ D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */, D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */, D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */, + D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */, D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */, D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */, ); @@ -2151,6 +2154,7 @@ D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */, D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */, D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */, + D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */, D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */, D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */, D620483423D3801D008A63EF /* LinkTextView.swift in Sources */, diff --git a/Tusker/Screens/Utilities/DiffableTimelineLikeTableViewController.swift b/Tusker/Screens/Utilities/DiffableTimelineLikeTableViewController.swift new file mode 100644 index 00000000..4f30fe2e --- /dev/null +++ b/Tusker/Screens/Utilities/DiffableTimelineLikeTableViewController.swift @@ -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: EnhancedTableViewController, RefreshableViewController { + + typealias Snapshot = NSDiffableDataSourceSnapshot + typealias LoadResult = Result + + private let pageSize = 20 + + private(set) var state = State.unloaded + private var lastLastVisibleRow: IndexPath? + + private(set) var dataSource: UITableViewDiffableDataSource! + + 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() + } +} diff --git a/Tusker/Screens/Utilities/TimelineLikeTableViewController.swift b/Tusker/Screens/Utilities/TimelineLikeTableViewController.swift index eb999268..177ca134 100644 --- a/Tusker/Screens/Utilities/TimelineLikeTableViewController.swift +++ b/Tusker/Screens/Utilities/TimelineLikeTableViewController.swift @@ -9,8 +9,8 @@ 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. +/// 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