forked from shadowfacts/Tusker
Timeline jump to present
This commit is contained in:
parent
8276e99d27
commit
0fddf94292
|
@ -11,7 +11,9 @@ import Foundation
|
||||||
public enum RequestRange {
|
public enum RequestRange {
|
||||||
case `default`
|
case `default`
|
||||||
case count(Int)
|
case count(Int)
|
||||||
|
/// Chronologically immediately before the given ID
|
||||||
case before(id: String, count: Int?)
|
case before(id: String, count: Int?)
|
||||||
|
/// Chronologically immediately after the given ID
|
||||||
case after(id: String, count: Int?)
|
case after(id: String, count: Int?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -97,7 +97,7 @@ class TrendingStatusesViewController: UIViewController {
|
||||||
do {
|
do {
|
||||||
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0
|
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0
|
||||||
} catch {
|
} catch {
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
await dataSource.apply(snapshot)
|
await dataSource.apply(snapshot)
|
||||||
let config = ToastConfiguration(from: error, with: "Loading Trending Posts", in: self) { toast in
|
let config = ToastConfiguration(from: error, with: "Loading Trending Posts", in: self) { toast in
|
||||||
toast.dismissToast(animated: true)
|
toast.dismissToast(animated: true)
|
||||||
|
|
|
@ -25,8 +25,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
private var older: RequestRange?
|
private var older: RequestRange?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
var collectionView: UICollectionView {
|
var collectionView: UICollectionView! {
|
||||||
view as! UICollectionView
|
view as? UICollectionView
|
||||||
}
|
}
|
||||||
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
private(set) var headerCell: ProfileHeaderCollectionViewCell?
|
private(set) var headerCell: ProfileHeaderCollectionViewCell?
|
||||||
|
@ -157,7 +157,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
if case .notLoadedInitial = await controller.state {
|
if case .notLoadedInitial = controller.state {
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import UIKit
|
||||||
|
|
||||||
class TimelineGapCollectionViewCell: UICollectionViewCell {
|
class TimelineGapCollectionViewCell: UICollectionViewCell {
|
||||||
|
|
||||||
var fillGap: ((TimelineLikeController<TimelineViewController.TimelineItem>.GapDirection) -> Void)?
|
var fillGap: ((TimelineGapDirection) -> Void)?
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
|
|
|
@ -16,14 +16,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
|
|
||||||
private(set) var controller: TimelineLikeController<TimelineItem>!
|
private(set) var controller: TimelineLikeController<TimelineItem>!
|
||||||
let confirmLoadMore = PassthroughSubject<Void, Never>()
|
let confirmLoadMore = PassthroughSubject<Void, Never>()
|
||||||
private var newer: RequestRange?
|
|
||||||
private var older: RequestRange?
|
|
||||||
// stored separately because i don't want to query the snapshot every time the user scrolls
|
// stored separately because i don't want to query the snapshot every time the user scrolls
|
||||||
private var isShowingTimelineDescription = false
|
private var isShowingTimelineDescription = false
|
||||||
|
|
||||||
var collectionView: UICollectionView {
|
private(set) var collectionView: UICollectionView!
|
||||||
view as! UICollectionView
|
|
||||||
}
|
|
||||||
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
|
||||||
init(for timeline: Timeline, mastodonController: MastodonController!) {
|
init(for timeline: Timeline, mastodonController: MastodonController!) {
|
||||||
|
@ -42,7 +38,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
override func loadView() {
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
var config = UICollectionLayoutListConfiguration(appearance: .plain)
|
var config = UICollectionLayoutListConfiguration(appearance: .plain)
|
||||||
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
|
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
|
||||||
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
|
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
|
||||||
|
@ -66,9 +64,17 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
let layout = UICollectionViewCompositionalLayout.list(using: config)
|
let layout = UICollectionViewCompositionalLayout.list(using: config)
|
||||||
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
collectionView.delegate = self
|
collectionView.delegate = self
|
||||||
collectionView.dragDelegate = self
|
collectionView.dragDelegate = self
|
||||||
|
collectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(collectionView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
registerTimelineLikeCells()
|
registerTimelineLikeCells()
|
||||||
collectionView.register(PublicTimelineDescriptionCollectionViewCell.self, forCellWithReuseIdentifier: "publicTimelineDescription")
|
collectionView.register(PublicTimelineDescriptionCollectionViewCell.self, forCellWithReuseIdentifier: "publicTimelineDescription")
|
||||||
|
@ -79,23 +85,31 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
collectionView.refreshControl = UIRefreshControl()
|
collectionView.refreshControl = UIRefreshControl()
|
||||||
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
|
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
|
||||||
#endif
|
#endif
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "square.split.1x2.fill"), primaryAction: UIAction(handler: { [unowned self] _ in
|
// navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "square.split.1x2.fill"), primaryAction: UIAction(handler: { [unowned self] _ in
|
||||||
|
// var snapshot = self.dataSource.snapshot()
|
||||||
|
// let statuses = snapshot.itemIdentifiers(inSection: .statuses)
|
||||||
|
// if statuses.count > 20 {
|
||||||
|
// let toRemove = Array(Array(statuses.dropFirst(10)).dropLast(10))
|
||||||
|
// if !toRemove.isEmpty {
|
||||||
|
// print("REMOVING MIDDLE \(toRemove.count) STATUSES")
|
||||||
|
// snapshot.insertItems([.gap], beforeItem: toRemove.first!)
|
||||||
|
// snapshot.deleteItems(toRemove)
|
||||||
|
// self.dataSource.apply(snapshot)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }))
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "clock.arrow.circlepath"), primaryAction: UIAction(handler: { [unowned self] _ in
|
||||||
var snapshot = self.dataSource.snapshot()
|
var snapshot = self.dataSource.snapshot()
|
||||||
let statuses = snapshot.itemIdentifiers(inSection: .statuses)
|
let statuses = snapshot.itemIdentifiers(inSection: .statuses)
|
||||||
if statuses.count > 20 {
|
if statuses.count > 20 {
|
||||||
let toRemove = Array(Array(statuses.dropFirst(10)).dropLast(10))
|
let toRemove = Array(statuses.dropLast(20))
|
||||||
if !toRemove.isEmpty {
|
|
||||||
print("REMOVING MIDDLE \(toRemove.count) STATUSES")
|
|
||||||
snapshot.insertItems([.gap], beforeItem: toRemove.first!)
|
|
||||||
snapshot.deleteItems(toRemove)
|
snapshot.deleteItems(toRemove)
|
||||||
self.dataSource.apply(snapshot)
|
self.dataSource.apply(snapshot)
|
||||||
}
|
// if case .status(id: let id, state: _) = snapshot.itemIdentifiers(inSection: .statuses).first {
|
||||||
|
// self.controller.dataSource.state.newer = .after(id: id, count: nil)
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
#else
|
#else
|
||||||
|
@ -167,7 +181,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
if case .notLoadedInitial = await controller.state {
|
if case .notLoadedInitial = controller.state {
|
||||||
await controller.loadInitial()
|
await controller.loadInitial()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -224,10 +238,53 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
|
|
||||||
@objc func refresh() {
|
@objc func refresh() {
|
||||||
Task {
|
Task {
|
||||||
if case .notLoadedInitial = await controller.state {
|
if case .notLoadedInitial = controller.state {
|
||||||
await controller.loadInitial()
|
await controller.loadInitial()
|
||||||
} else {
|
} else {
|
||||||
await controller.loadNewer()
|
// I'm not sure whether this should move into TimelineLikeController/TimelineLikeCollectionViewController
|
||||||
|
let (_, presentItems) = await (controller.loadNewer(), try? loadInitial())
|
||||||
|
if let presentItems,
|
||||||
|
case .status(id: let id, state: _) = dataSource.snapshot().itemIdentifiers(inSection: .statuses).first {
|
||||||
|
// if there's no overlap between presentItems and the existing items in the data source, prompt the user to scroll to present
|
||||||
|
if !presentItems.contains(id) {
|
||||||
|
var snapshot = self.dataSource.snapshot()
|
||||||
|
let currentItems = snapshot.itemIdentifiers(inSection: .statuses)
|
||||||
|
guard let item = currentItems.first,
|
||||||
|
case .status(id: id, state: _) = item else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove any existing gap if there is one
|
||||||
|
if let index = currentItems.lastIndex(of: .gap) {
|
||||||
|
snapshot.deleteItems(Array(currentItems[index...]))
|
||||||
|
}
|
||||||
|
snapshot.insertItems([.gap], beforeItem: item)
|
||||||
|
snapshot.insertItems(presentItems.map { .status(id: $0, state: .unknown) }, beforeItem: .gap)
|
||||||
|
|
||||||
|
// use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back
|
||||||
|
let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)!
|
||||||
|
snapshotView.layer.zPosition = 1000
|
||||||
|
snapshotView.frame = view.bounds
|
||||||
|
view.addSubview(snapshotView)
|
||||||
|
|
||||||
|
let bottomOffset = collectionView.contentSize.height - collectionView.contentOffset.y
|
||||||
|
self.dataSource.apply(snapshot, animatingDifferences: false) {
|
||||||
|
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
|
||||||
|
snapshotView.removeFromSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = ToastConfiguration(title: "Jump to present")
|
||||||
|
config.edge = .top
|
||||||
|
config.systemImageName = "arrow.up"
|
||||||
|
config.dismissAutomaticallyAfter = 2
|
||||||
|
config.action = { [unowned self] toast in
|
||||||
|
toast.dismissToast(animated: true)
|
||||||
|
|
||||||
|
self.collectionView.scrollToTop()
|
||||||
|
}
|
||||||
|
self.showToast(configuration: config, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#if !targetEnvironment(macCatalyst)
|
#if !targetEnvironment(macCatalyst)
|
||||||
collectionView.refreshControl?.endRefreshing()
|
collectionView.refreshControl?.endRefreshing()
|
||||||
|
@ -318,50 +375,48 @@ extension TimelineViewController {
|
||||||
func loadInitial() async throws -> [TimelineItem] {
|
func loadInitial() async throws -> [TimelineItem] {
|
||||||
try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
|
try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
|
||||||
|
|
||||||
guard let mastodonController else {
|
|
||||||
throw Error.noClient
|
|
||||||
}
|
|
||||||
|
|
||||||
let request = Client.getStatuses(timeline: timeline)
|
let request = Client.getStatuses(timeline: timeline)
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
let (statuses, _) = try await mastodonController.run(request)
|
||||||
|
|
||||||
if !statuses.isEmpty {
|
await withCheckedContinuation { continuation in
|
||||||
newer = .after(id: statuses.first!.id, count: nil)
|
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
older = .before(id: statuses.last!.id, count: nil)
|
continuation.resume()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await withCheckedContinuation { continuation in
|
return statuses.map(\.id)
|
||||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
|
||||||
continuation.resume(returning: statuses.map(\.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadNewer() async throws -> [TimelineItem] {
|
func loadNewer() async throws -> [TimelineItem] {
|
||||||
guard let newer else {
|
let statusesSection = dataSource.snapshot().indexOfSection(.statuses)!
|
||||||
|
guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: 0, section: statusesSection)) else {
|
||||||
throw Error.noNewer
|
throw Error.noNewer
|
||||||
}
|
}
|
||||||
|
let newer = RequestRange.after(id: id, count: nil)
|
||||||
|
|
||||||
let request = Client.getStatuses(timeline: timeline, range: newer)
|
let request = Client.getStatuses(timeline: timeline, range: newer)
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
let (statuses, _) = try await mastodonController.run(request)
|
||||||
|
|
||||||
guard !statuses.isEmpty else {
|
guard !statuses.isEmpty else {
|
||||||
throw Error.allCaughtUp
|
throw TimelineViewController.Error.allCaughtUp
|
||||||
}
|
}
|
||||||
|
|
||||||
self.newer = .after(id: statuses.first!.id, count: nil)
|
await withCheckedContinuation { continuation in
|
||||||
|
|
||||||
return await withCheckedContinuation { continuation in
|
|
||||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
continuation.resume(returning: statuses.map(\.id))
|
continuation.resume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return statuses.map(\.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadOlder() async throws -> [TimelineItem] {
|
func loadOlder() async throws -> [TimelineItem] {
|
||||||
guard let older else {
|
let snapshot = dataSource.snapshot()
|
||||||
throw Error.noOlder
|
let statusesSection = snapshot.indexOfSection(.statuses)!
|
||||||
|
guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: snapshot.numberOfItems(inSection: .statuses) - 1, section: statusesSection)) else {
|
||||||
|
throw Error.noNewer
|
||||||
}
|
}
|
||||||
|
let older = RequestRange.before(id: id, count: nil)
|
||||||
|
|
||||||
let request = Client.getStatuses(timeline: timeline, range: older)
|
let request = Client.getStatuses(timeline: timeline, range: older)
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
let (statuses, _) = try await mastodonController.run(request)
|
||||||
|
@ -370,16 +425,16 @@ extension TimelineViewController {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
self.older = .before(id: statuses.last!.id, count: nil)
|
await withCheckedContinuation { continuation in
|
||||||
|
|
||||||
return await withCheckedContinuation { continuation in
|
|
||||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
continuation.resume(returning: statuses.map(\.id))
|
continuation.resume()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadGap(in direction: TimelineLikeController<TimelineItem>.GapDirection) async throws -> [TimelineItem] {
|
return statuses.map(\.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] {
|
||||||
guard let gapIndexPath = dataSource.indexPath(for: .gap) else {
|
guard let gapIndexPath = dataSource.indexPath(for: .gap) else {
|
||||||
throw Error.noGap
|
throw Error.noGap
|
||||||
}
|
}
|
||||||
|
@ -410,14 +465,16 @@ extension TimelineViewController {
|
||||||
|
|
||||||
// NOTE: closing the gap (if necessary) happens in handleFillGap
|
// NOTE: closing the gap (if necessary) happens in handleFillGap
|
||||||
|
|
||||||
return await withCheckedContinuation { continuation in
|
await withCheckedContinuation { continuation in
|
||||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
continuation.resume(returning: statuses.map(\.id))
|
continuation.resume()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLoadGapError(_ error: Swift.Error, direction: TimelineLikeController<String>.GapDirection) async {
|
return statuses.map(\.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async {
|
||||||
// TODO: better title, involving direction?
|
// TODO: better title, involving direction?
|
||||||
let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] toast in
|
let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] toast in
|
||||||
toast.dismissToast(animated: true)
|
toast.dismissToast(animated: true)
|
||||||
|
@ -428,7 +485,7 @@ extension TimelineViewController {
|
||||||
self.showToast(configuration: config, animated: true)
|
self.showToast(configuration: config, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleFillGap(_ timelineItems: [String], direction: TimelineLikeController<String>.GapDirection) async {
|
func handleFillGap(_ timelineItems: [String], direction: TimelineGapDirection) async {
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
let addedItems: Bool
|
let addedItems: Bool
|
||||||
|
|
||||||
|
@ -496,14 +553,14 @@ extension TimelineViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if we didn't add any items, that implies the gap was removed, and we want to animate that to make clear what's happening
|
// if we didn't add any items, that implies the gap was removed, and we want to to make clear what's happening
|
||||||
if !addedItems {
|
if !addedItems {
|
||||||
await apply(snapshot, animatingDifferences: true)
|
var config = ToastConfiguration(title: "There's nothing in between!")
|
||||||
let config = ToastConfiguration(title: "There's nothing in between!")
|
config.dismissAutomaticallyAfter = 2
|
||||||
showToast(configuration: config, animated: true)
|
showToast(configuration: config, animated: true)
|
||||||
} else {
|
|
||||||
await apply(snapshot, animatingDifferences: false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await apply(snapshot, animatingDifferences: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Error: TimelineLikeCollectionViewError {
|
enum Error: TimelineLikeCollectionViewError {
|
||||||
|
|
|
@ -19,7 +19,7 @@ protocol TimelineLikeCollectionViewController: UIViewController, TimelineLikeCon
|
||||||
var controller: TimelineLikeController<TimelineItem>! { get }
|
var controller: TimelineLikeController<TimelineItem>! { get }
|
||||||
var confirmLoadMore: PassthroughSubject<Void, Never> { get }
|
var confirmLoadMore: PassthroughSubject<Void, Never> { get }
|
||||||
|
|
||||||
var collectionView: UICollectionView { get }
|
var collectionView: UICollectionView! { get }
|
||||||
var dataSource: UICollectionViewDiffableDataSource<Section, Item>! { get }
|
var dataSource: UICollectionViewDiffableDataSource<Section, Item>! { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,7 +64,7 @@ extension TimelineLikeCollectionViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleAddLoadingIndicator() async {
|
func handleAddLoadingIndicator() async {
|
||||||
if case .loadingInitial(_, _) = await controller.state,
|
if case .loadingInitial(_, _) = controller.state,
|
||||||
let refreshControl = collectionView.refreshControl,
|
let refreshControl = collectionView.refreshControl,
|
||||||
refreshControl.isRefreshing {
|
refreshControl.isRefreshing {
|
||||||
refreshControl.beginRefreshing()
|
refreshControl.beginRefreshing()
|
||||||
|
@ -85,7 +85,7 @@ extension TimelineLikeCollectionViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleRemoveLoadingIndicator() async {
|
func handleRemoveLoadingIndicator() async {
|
||||||
if case .loadingInitial(_, _) = await controller.state,
|
if case .loadingInitial(_, _) = controller.state,
|
||||||
let refreshControl = collectionView.refreshControl,
|
let refreshControl = collectionView.refreshControl,
|
||||||
refreshControl.isRefreshing {
|
refreshControl.isRefreshing {
|
||||||
refreshControl.endRefreshing()
|
refreshControl.endRefreshing()
|
||||||
|
@ -180,14 +180,14 @@ extension TimelineLikeCollectionViewController {
|
||||||
await apply(snapshot, animatingDifferences: false)
|
await apply(snapshot, animatingDifferences: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadGap(in direction: TimelineLikeController<TimelineItem>.GapDirection) async throws -> [TimelineItem] {
|
func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] {
|
||||||
fatalError("not supported by \(String(describing: type(of: self)))")
|
fatalError("not supported by \(String(describing: type(of: self)))")
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLoadGapError(_ error: Swift.Error, direction: TimelineLikeController<TimelineItem>.GapDirection) async {
|
func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineLikeController<TimelineItem>.GapDirection) async {
|
func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineGapDirection) async {
|
||||||
fatalError("not supported by \(String(describing: type(of: self)))")
|
fatalError("not supported by \(String(describing: type(of: self)))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -217,7 +217,7 @@ extension TimelineLikeCollectionViewController {
|
||||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell
|
||||||
cell.confirmLoadMore = self.confirmLoadMore
|
cell.confirmLoadMore = self.confirmLoadMore
|
||||||
Task {
|
Task {
|
||||||
if case .loadingOlder(_, _) = await controller.state {
|
if case .loadingOlder(_, _) = controller.state {
|
||||||
cell.isLoading = true
|
cell.isLoading = true
|
||||||
} else {
|
} else {
|
||||||
cell.isLoading = false
|
cell.isLoading = false
|
||||||
|
|
|
@ -16,11 +16,11 @@ protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
|
||||||
|
|
||||||
func loadNewer() async throws -> [TimelineItem]
|
func loadNewer() async throws -> [TimelineItem]
|
||||||
|
|
||||||
func loadOlder() async throws -> [TimelineItem]
|
|
||||||
|
|
||||||
func canLoadOlder() async -> Bool
|
func canLoadOlder() async -> Bool
|
||||||
|
|
||||||
func loadGap(in direction: TimelineLikeController<TimelineItem>.GapDirection) async throws -> [TimelineItem]
|
func loadOlder() async throws -> [TimelineItem]
|
||||||
|
|
||||||
|
func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem]
|
||||||
|
|
||||||
func handleAddLoadingIndicator() async
|
func handleAddLoadingIndicator() async
|
||||||
func handleRemoveLoadingIndicator() async
|
func handleRemoveLoadingIndicator() async
|
||||||
|
@ -30,15 +30,16 @@ protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
|
||||||
func handlePrependItems(_ timelineItems: [TimelineItem]) async
|
func handlePrependItems(_ timelineItems: [TimelineItem]) async
|
||||||
func handleLoadOlderError(_ error: Swift.Error) async
|
func handleLoadOlderError(_ error: Swift.Error) async
|
||||||
func handleAppendItems(_ timelineItems: [TimelineItem]) async
|
func handleAppendItems(_ timelineItems: [TimelineItem]) async
|
||||||
func handleLoadGapError(_ error: Swift.Error, direction: TimelineLikeController<TimelineItem>.GapDirection) async
|
func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async
|
||||||
func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineLikeController<TimelineItem>.GapDirection) async
|
func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineGapDirection) async
|
||||||
}
|
}
|
||||||
|
|
||||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TimelineLikeController")
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TimelineLikeController")
|
||||||
|
|
||||||
actor TimelineLikeController<Item> {
|
@MainActor
|
||||||
|
class TimelineLikeController<Item> {
|
||||||
|
|
||||||
unowned var delegate: any TimelineLikeControllerDelegate<Item>
|
private unowned var delegate: any TimelineLikeControllerDelegate<Item>
|
||||||
|
|
||||||
private(set) var state = State.notLoadedInitial {
|
private(set) var state = State.notLoadedInitial {
|
||||||
willSet {
|
willSet {
|
||||||
|
@ -130,7 +131,7 @@ actor TimelineLikeController<Item> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fillGap(in direction: GapDirection) async {
|
func fillGap(in direction: TimelineGapDirection) async {
|
||||||
guard state == .idle else {
|
guard state == .idle else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -190,7 +191,7 @@ actor TimelineLikeController<Item> {
|
||||||
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
||||||
case loadingNewer(LoadAttemptToken)
|
case loadingNewer(LoadAttemptToken)
|
||||||
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
||||||
case loadingGap(LoadAttemptToken, GapDirection)
|
case loadingGap(LoadAttemptToken, TimelineGapDirection)
|
||||||
|
|
||||||
var debugDescription: String {
|
var debugDescription: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
@ -293,8 +294,8 @@ actor TimelineLikeController<Item> {
|
||||||
case prependItems([Item], LoadAttemptToken)
|
case prependItems([Item], LoadAttemptToken)
|
||||||
case loadOlderError(Error, LoadAttemptToken)
|
case loadOlderError(Error, LoadAttemptToken)
|
||||||
case appendItems([Item], LoadAttemptToken)
|
case appendItems([Item], LoadAttemptToken)
|
||||||
case loadGapError(Error, GapDirection, LoadAttemptToken)
|
case loadGapError(Error, TimelineGapDirection, LoadAttemptToken)
|
||||||
case fillGap([Item], GapDirection, LoadAttemptToken)
|
case fillGap([Item], TimelineGapDirection, LoadAttemptToken)
|
||||||
|
|
||||||
var debugDescription: String {
|
var debugDescription: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
@ -356,11 +357,11 @@ actor TimelineLikeController<Item> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum GapDirection {
|
}
|
||||||
|
|
||||||
|
enum TimelineGapDirection {
|
||||||
/// Fill in below the gap. I.e., statuses that are immediately newer than the status below the gap.
|
/// Fill in below the gap. I.e., statuses that are immediately newer than the status below the gap.
|
||||||
case below
|
case below
|
||||||
/// Fill in above the gap. I.e., statuses that are immediately older than the status above the gap.
|
/// Fill in above the gap. I.e., statuses that are immediately older than the status above the gap.
|
||||||
case above
|
case above
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in New Issue