Extract a bunch of timeline view controller stuff to separate protocol

This commit is contained in:
Shadowfacts 2022-09-24 11:32:50 -04:00
parent d18a4b3c42
commit a682c8f5cc
3 changed files with 212 additions and 129 deletions

View File

@ -35,6 +35,7 @@
D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */; }; D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */; };
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */; }; D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */; };
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; }; D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; };
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; };
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */; }; D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */; };
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; }; D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; };
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; }; D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; };
@ -380,6 +381,7 @@
D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HashtagTableViewCell.xib; sourceTree = "<group>"; }; D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HashtagTableViewCell.xib; sourceTree = "<group>"; };
D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreCollectionViewCell.swift; sourceTree = "<group>"; }; D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreCollectionViewCell.swift; sourceTree = "<group>"; };
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = "<group>"; }; D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = "<group>"; };
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = "<group>"; };
D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = "<group>"; }; D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = "<group>"; };
D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = "<group>"; }; D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = "<group>"; };
D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = "<group>"; }; D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = "<group>"; };
@ -1298,6 +1300,7 @@
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */, D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */, D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */, D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */,
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */, D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */, D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */, D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
@ -1900,6 +1903,7 @@
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */, D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */, D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */, D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */, D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */, D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */, D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */,

View File

@ -14,20 +14,19 @@ import SwiftSoup
// TODO: gonna need a thing to replicate all of EnhancedTableViewController // TODO: gonna need a thing to replicate all of EnhancedTableViewController
class TimelineViewController: UIViewController { class TimelineViewController: UIViewController, TimelineLikeCollectionViewController {
let timeline: Timeline let timeline: Timeline
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
private var controller: TimelineLikeController<TimelineItem>! private(set) var controller: TimelineLikeController<TimelineItem>!
private var confirmLoadMore = PassthroughSubject<Void, Never>() let confirmLoadMore = PassthroughSubject<Void, Never>()
private var newer: RequestRange? private var newer: RequestRange?
private var older: RequestRange? private var older: RequestRange?
private var collectionView: UICollectionView { var collectionView: UICollectionView {
view as! UICollectionView view as! UICollectionView
} }
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
init(for timeline: Timeline, mastodonController: MastodonController!) { init(for timeline: Timeline, mastodonController: MastodonController!) {
self.timeline = timeline self.timeline = timeline
@ -61,12 +60,12 @@ class TimelineViewController: UIViewController {
view = UICollectionView(frame: .zero, collectionViewLayout: layout) view = UICollectionView(frame: .zero, collectionViewLayout: layout)
// TODO: delegates // TODO: delegates
collectionView.delegate = self collectionView.delegate = self
// collectionView.dragDelegate = self // collectionView.dragDelegate = self
dataSource = createDataSource() dataSource = createDataSource()
applyInitialSnapshot() applyInitialSnapshot()
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
let refreshControl = UIRefreshControl(frame: .zero, primaryAction: UIAction(handler: { [unowned self] _ in let refreshControl = UIRefreshControl(frame: .zero, primaryAction: UIAction(handler: { [unowned self] _ in
Task { Task {
await self.controller.loadNewer() await self.controller.loadNewer()
@ -74,7 +73,7 @@ class TimelineViewController: UIViewController {
} }
})) }))
collectionView.refreshControl = refreshControl collectionView.refreshControl = refreshControl
#endif #endif
} }
override func viewDidLoad() { override func viewDidLoad() {
@ -135,18 +134,26 @@ class TimelineViewController: UIViewController {
} }
extension TimelineViewController { extension TimelineViewController {
enum Section: Hashable { enum Section: TimelineLikeCollectionViewSection {
case header case header
case statuses case statuses
case footer case footer
static var entries: Self { .statuses }
} }
enum Item: Hashable { enum Item: TimelineLikeCollectionViewItem {
typealias TimelineItem = String // status ID
case status(id: String, state: StatusState) case status(id: String, state: StatusState)
case loadingIndicator case loadingIndicator
case confirmLoadMore case confirmLoadMore
// // TODO: remove local param from this // // TODO: remove local param from this
// case publicTimelineDescription(local: Bool) // case publicTimelineDescription(local: Bool)
static func fromTimelineItem(_ id: String) -> Self {
return .status(id: id, state: .unknown)
}
static func ==(lhs: Item, rhs: Item) -> Bool { static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
case let (.status(id: a, state: _), .status(id: b, state: _)): case let (.status(id: a, state: _), .status(id: b, state: _)):
@ -188,7 +195,8 @@ extension TimelineViewController {
} }
} }
extension TimelineViewController: TimelineLikeControllerDelegate { // MARK: - TimelineLikeControllerDelegate
extension TimelineViewController {
typealias TimelineItem = String // status ID typealias TimelineItem = String // status ID
func loadInitial() async throws -> [TimelineItem] { func loadInitial() async throws -> [TimelineItem] {
@ -255,120 +263,7 @@ extension TimelineViewController: TimelineLikeControllerDelegate {
} }
} }
func canLoadOlder() async -> Bool { enum Error: TimelineLikeCollectionViewError {
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 dataSource.apply(snapshot, animatingDifferences: false)
}
for await _ in self.confirmLoadMore.values {
return true
}
fatalError("unreachable")
} else {
return true
}
}
func handleEvent(_ event: TimelineLikeController<TimelineItem>.Event) async {
switch event {
case .addLoadingIndicator:
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 dataSource.apply(snapshot, animatingDifferences: false)
case .removeLoadingIndicator:
let oldContentOffset = collectionView.contentOffset
var snapshot = dataSource.snapshot()
snapshot.deleteSections([.footer])
await dataSource.apply(snapshot, animatingDifferences: false)
collectionView.contentOffset = oldContentOffset
case .loadAllError(let error, _):
let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] (toast: ToastView) in
toast.dismissToast(animated: true)
Task {
await self?.controller.loadInitial()
}
}
self.showToast(configuration: config, animated: true)
case .replaceAllItems(let ids, _):
var snapshot = dataSource.snapshot()
if snapshot.sectionIdentifiers.contains(.statuses) {
snapshot.deleteSections([.statuses])
}
snapshot.appendSections([.statuses])
snapshot.appendItems(ids.map { .status(id: $0, state: .unknown) }, toSection: .statuses)
await dataSource.apply(snapshot, animatingDifferences: false)
case .loadNewerError(Error.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)
case .loadNewerError(let error, _):
let config = ToastConfiguration(from: error, with: "Error Loading Newer", in: self) { [weak self] (toast: ToastView) in
toast.dismissToast(animated: true)
Task {
await self?.controller.loadNewer()
}
}
self.showToast(configuration: config, animated: true)
case .prependItems(let ids, _):
var snapshot = dataSource.snapshot()
let items = ids.map { Item.status(id: $0, state: .unknown) }
let first = snapshot.itemIdentifiers(inSection: .statuses).first
if let first {
snapshot.insertItems(items, beforeItem: first)
} else {
snapshot.appendItems(items, toSection: .statuses)
}
await dataSource.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 position in the list (don't scroll to top)
collectionView.scrollToItem(at: indexPath, at: .top, animated: false)
}
case .loadOlderError(let error, _):
let config = ToastConfiguration(from: error, with: "Error Loading Older", in: self) { [weak self] (toast: ToastView) in
toast.dismissToast(animated: true)
Task {
await self?.controller.loadOlder()
}
}
self.showToast(configuration: config, animated: true)
case .appendItems(let ids, _):
var snapshot = dataSource.snapshot()
if snapshot.itemIdentifiers.contains(.confirmLoadMore) {
snapshot.deleteItems([.confirmLoadMore])
}
snapshot.appendItems(ids.map { .status(id: $0, state: .unknown) }, toSection: .statuses)
await dataSource.apply(snapshot, animatingDifferences: false)
}
}
enum Error: Swift.Error {
case noClient case noClient
case noNewer case noNewer
case noOlder case noOlder
@ -391,6 +286,3 @@ extension TimelineViewController: UICollectionViewDelegate {
} }
} }
} }
extension TimelineViewController: ToastableViewController {
}

View File

@ -0,0 +1,187 @@
//
// 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 {
static var entries: Self { get }
static var footer: Self { get }
}
protocol TimelineLikeCollectionViewItem: Hashable {
associatedtype TimelineItem
static var loadingIndicator: Self { get }
static var confirmLoadMore: Self { get }
static func fromTimelineItem(_ item: TimelineItem) -> Self
}
// TODO: equatable might not be the best for this?
protocol TimelineLikeCollectionViewError: Error, Equatable {
static var allCaughtUp: Self { get }
}
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 dataSource.apply(snapshot, animatingDifferences: false)
}
for await _ in confirmLoadMore.values {
return true
}
fatalError("unreachable")
} else {
return true
}
}
func handleEvent(_ event: TimelineLikeController<TimelineItem>.Event) async {
switch event {
case .addLoadingIndicator:
await handleAddLoadingIndicator()
case .removeLoadingIndicator:
await handleRemoveLoadingIndicator()
case .loadAllError(let error, _):
await handleLoadAllError(error)
case .replaceAllItems(let items, _):
await handleReplaceAllItems(items)
case .loadNewerError(let error, _):
await handleLoadNewerError(error)
case .prependItems(let items, _):
await handlePrependItems(items)
case .loadOlderError(let error, _):
await handleLoadOlderError(error)
case .appendItems(let items, _):
await handleAppendItems(items)
}
}
func handleAddLoadingIndicator() async {
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 dataSource.apply(snapshot, animatingDifferences: false)
}
func handleRemoveLoadingIndicator() async {
let oldContentOffset = collectionView.contentOffset
var snapshot = dataSource.snapshot()
snapshot.deleteSections([.footer])
await dataSource.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 dataSource.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 dataSource.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 dataSource.apply(snapshot, animatingDifferences: false)
}
}