230 lines
9.2 KiB
Swift
230 lines
9.2 KiB
Swift
//
|
|
// TimelineLikeCollectionViewController.swift
|
|
// Tusker
|
|
//
|
|
// Created by Shadowfacts on 9/24/22.
|
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
import Combine
|
|
|
|
@MainActor
|
|
protocol TimelineLikeCollectionViewController: UIViewController, TimelineLikeControllerDelegate, ToastableViewController {
|
|
associatedtype Section: TimelineLikeCollectionViewSection
|
|
associatedtype Item: TimelineLikeCollectionViewItem where Item.TimelineItem == Self.TimelineItem
|
|
associatedtype Error: TimelineLikeCollectionViewError
|
|
|
|
// this needs to be an IUO because it can't be set until after the super init is called, so that self can be passed as the delegate param
|
|
var controller: TimelineLikeController<TimelineItem>! { get }
|
|
var confirmLoadMore: PassthroughSubject<Void, Never> { get }
|
|
|
|
var collectionView: UICollectionView! { get }
|
|
var dataSource: UICollectionViewDiffableDataSource<Section, Item>! { get }
|
|
}
|
|
|
|
protocol TimelineLikeCollectionViewSection: Hashable, Sendable {
|
|
static var entries: Self { get }
|
|
static var footer: Self { get }
|
|
}
|
|
|
|
protocol TimelineLikeCollectionViewItem: Hashable, Sendable {
|
|
associatedtype TimelineItem
|
|
|
|
static var loadingIndicator: Self { get }
|
|
static var confirmLoadMore: Self { get }
|
|
|
|
@MainActor
|
|
static func fromTimelineItem(_ item: TimelineItem) -> Self
|
|
}
|
|
|
|
// TODO: equatable might not be the best for this?
|
|
protocol TimelineLikeCollectionViewError: Error, Equatable {
|
|
static var allCaughtUp: Self { get }
|
|
}
|
|
|
|
// MARK: TimelineLikeControllerDelegate
|
|
extension TimelineLikeCollectionViewController {
|
|
func canLoadOlder() async -> Bool {
|
|
if Preferences.shared.disableInfiniteScrolling {
|
|
var snapshot = dataSource.snapshot()
|
|
if !snapshot.itemIdentifiers.contains(.confirmLoadMore) {
|
|
if !snapshot.sectionIdentifiers.contains(.footer) {
|
|
snapshot.appendSections([.footer])
|
|
}
|
|
snapshot.appendItems([.confirmLoadMore], toSection: .footer)
|
|
await apply(snapshot, animatingDifferences: false)
|
|
}
|
|
for await _ in confirmLoadMore.values {
|
|
return true
|
|
}
|
|
fatalError("unreachable")
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
|
|
func handleAddLoadingIndicator() async {
|
|
if case .loadingInitial(_, _) = controller.state,
|
|
let refreshControl = collectionView.refreshControl,
|
|
refreshControl.isRefreshing {
|
|
refreshControl.beginRefreshing()
|
|
// if we're loading initial and the refresh control is already going, we don't need to add another indicator
|
|
return
|
|
}
|
|
|
|
var snapshot = dataSource.snapshot()
|
|
if !snapshot.sectionIdentifiers.contains(.footer) {
|
|
snapshot.appendSections([.footer])
|
|
}
|
|
if snapshot.itemIdentifiers.contains(.confirmLoadMore) {
|
|
snapshot.reconfigureItems([.confirmLoadMore])
|
|
} else {
|
|
snapshot.appendItems([.loadingIndicator], toSection: .footer)
|
|
}
|
|
await apply(snapshot, animatingDifferences: false)
|
|
}
|
|
|
|
func handleRemoveLoadingIndicator() async {
|
|
if case .loadingInitial(_, _) = controller.state,
|
|
let refreshControl = collectionView.refreshControl,
|
|
refreshControl.isRefreshing {
|
|
refreshControl.endRefreshing()
|
|
return
|
|
}
|
|
|
|
let oldContentOffset = collectionView.contentOffset
|
|
var snapshot = dataSource.snapshot()
|
|
snapshot.deleteSections([.footer])
|
|
await apply(snapshot, animatingDifferences: false)
|
|
// prevent the collection view from scrolling as we remove the loading indicator and add the timeline items
|
|
collectionView.contentOffset = oldContentOffset
|
|
}
|
|
|
|
func handleLoadAllError(_ error: Swift.Error) async {
|
|
let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] toast in
|
|
toast.dismissToast(animated: true)
|
|
Task {
|
|
await self?.controller.loadInitial()
|
|
}
|
|
}
|
|
self.showToast(configuration: config, animated: true)
|
|
}
|
|
|
|
func handleReplaceAllItems(_ timelineItems: [TimelineItem]) async {
|
|
var snapshot = dataSource.snapshot()
|
|
if snapshot.sectionIdentifiers.contains(.entries) {
|
|
snapshot.deleteSections([.entries])
|
|
}
|
|
snapshot.appendSections([.entries])
|
|
snapshot.appendItems(timelineItems.map { .fromTimelineItem($0) }, toSection: .entries)
|
|
await apply(snapshot, animatingDifferences: false)
|
|
}
|
|
|
|
func handleLoadNewerError(_ error: Swift.Error) async {
|
|
var config: ToastConfiguration
|
|
if let error = error as? Self.Error,
|
|
error == .allCaughtUp {
|
|
config = ToastConfiguration(title: "You're all caught up")
|
|
config.edge = .top
|
|
config.dismissAutomaticallyAfter = 2
|
|
config.action = { toast in
|
|
toast.dismissToast(animated: true)
|
|
}
|
|
} else {
|
|
config = ToastConfiguration(from: error, with: "Error Loading Newer", in: self) { [weak self] toast in
|
|
toast.dismissToast(animated: true)
|
|
Task {
|
|
await self?.controller.loadNewer()
|
|
}
|
|
}
|
|
}
|
|
self.showToast(configuration: config, animated: true)
|
|
}
|
|
|
|
func handlePrependItems(_ timelineItems: [TimelineItem]) async {
|
|
let items = timelineItems.map { Item.fromTimelineItem($0) }
|
|
var snapshot = dataSource.snapshot()
|
|
let first = snapshot.itemIdentifiers(inSection: .entries).first
|
|
if let first {
|
|
snapshot.insertItems(items, beforeItem: first)
|
|
} else {
|
|
snapshot.appendItems(items, toSection: .entries)
|
|
}
|
|
await apply(snapshot, animatingDifferences: false)
|
|
|
|
if let first,
|
|
let indexPath = dataSource.indexPath(for: first) {
|
|
// TODO: i can't tell if this actually works or not
|
|
// maintain the current scroll position in the list (don't scroll to top)
|
|
collectionView.scrollToItem(at: indexPath, at: .top, animated: false)
|
|
}
|
|
}
|
|
|
|
func handleLoadOlderError(_ error: Swift.Error) async {
|
|
let config = ToastConfiguration(from: error, with: "Error Loading Older", in: self) { [weak self] toast in
|
|
toast.dismissToast(animated: true)
|
|
Task {
|
|
await self?.controller.loadOlder()
|
|
}
|
|
}
|
|
self.showToast(configuration: config, animated: true)
|
|
}
|
|
|
|
func handleAppendItems(_ timelineItems: [TimelineItem]) async {
|
|
var snapshot = dataSource.snapshot()
|
|
// TODO: this might not be necessary, isn't the confirm item removed separately?
|
|
if snapshot.itemIdentifiers.contains(.confirmLoadMore) {
|
|
snapshot.deleteItems([.confirmLoadMore])
|
|
}
|
|
snapshot.appendItems(timelineItems.map { .fromTimelineItem($0) }, toSection: .entries)
|
|
await apply(snapshot, animatingDifferences: false)
|
|
}
|
|
|
|
func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] {
|
|
fatalError("not supported by \(String(describing: type(of: self)))")
|
|
}
|
|
|
|
func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async {
|
|
}
|
|
|
|
func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineGapDirection) async {
|
|
fatalError("not supported by \(String(describing: type(of: self)))")
|
|
}
|
|
}
|
|
|
|
extension TimelineLikeCollectionViewController {
|
|
// apply(_:animatingDifferences:) is marked as nonisolated, so just awaiting it doesn't dispatch to the main thread, unlike other async @MainActor methods
|
|
// but we always want to update the data source on the main thread for consistency, so this method does that
|
|
func apply(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, animatingDifferences: Bool) async {
|
|
let task = Task { @MainActor in
|
|
self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
|
|
}
|
|
await task.value
|
|
}
|
|
|
|
func registerTimelineLikeCells() {
|
|
collectionView.register(LoadingCollectionViewCell.self, forCellWithReuseIdentifier: "loadingIndicator")
|
|
collectionView.register(ConfirmLoadMoreCollectionViewCell.self, forCellWithReuseIdentifier: "confirmLoadMore")
|
|
}
|
|
|
|
func loadingIndicatorCell(for indexPath: IndexPath) -> UICollectionViewCell {
|
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "loadingIndicator", for: indexPath) as! LoadingCollectionViewCell
|
|
cell.indicator.startAnimating()
|
|
return cell
|
|
}
|
|
|
|
func confirmLoadMoreCell(for indexPath: IndexPath) -> UICollectionViewCell {
|
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell
|
|
cell.confirmLoadMore = self.confirmLoadMore
|
|
Task {
|
|
if case .loadingOlder(_, _) = controller.state {
|
|
cell.isLoading = true
|
|
} else {
|
|
cell.isLoading = false
|
|
}
|
|
}
|
|
return cell
|
|
}
|
|
}
|