From 841119949b65f5c1436e13204e34bc16ad4e8e8b Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 11 Feb 2023 18:21:40 -0500 Subject: [PATCH] Add infinite scrolling to trending hashtags screen See #355 --- .../Pachyderm/Sources/Pachyderm/Client.swift | 24 ++- .../TrendingHashtagsViewController.swift | 174 ++++++++++++++++-- .../Explore/TrendingLinksViewController.swift | 2 +- 3 files changed, 179 insertions(+), 21 deletions(-) diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift index cd6c3889..10926b2b 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Client.swift @@ -417,16 +417,28 @@ public class Client { } // MARK: - Instance - public static func getTrendingHashtags(limit: Int? = nil) -> Request<[Hashtag]> { - let parameters: [Parameter] - if let limit = limit { - parameters = ["limit" => limit] - } else { - parameters = [] + public static func getTrendingHashtagsDeprecated(limit: Int? = nil, offset: Int? = nil) -> Request<[Hashtag]> { + var parameters: [Parameter] = [] + if let limit { + parameters.append("limit" => limit) + } + if let offset { + parameters.append("offset" => offset) } return Request<[Hashtag]>(method: .get, path: "/api/v1/trends", queryParameters: parameters) } + public static func getTrendingHashtags(limit: Int? = nil, offset: Int? = nil) -> Request<[Hashtag]> { + var parameters: [Parameter] = [] + if let limit { + parameters.append("limit" => limit) + } + if let offset { + parameters.append("offset" => offset) + } + return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters) + } + public static func getTrendingStatuses(limit: Int? = nil) -> Request<[Status]> { let parameters: [Parameter] if let limit = limit { diff --git a/Tusker/Screens/Explore/TrendingHashtagsViewController.swift b/Tusker/Screens/Explore/TrendingHashtagsViewController.swift index e503e9d5..d7bd54c6 100644 --- a/Tusker/Screens/Explore/TrendingHashtagsViewController.swift +++ b/Tusker/Screens/Explore/TrendingHashtagsViewController.swift @@ -9,6 +9,7 @@ import UIKit import Pachyderm import WebURLFoundationExtras +import Combine class TrendingHashtagsViewController: UIViewController { @@ -17,6 +18,9 @@ class TrendingHashtagsViewController: UIViewController { private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! + private var state = State.unloaded + private var confirmLoadMore = PassthroughSubject() + init(mastodonController: MastodonController) { self.mastodonController = mastodonController @@ -32,9 +36,21 @@ class TrendingHashtagsViewController: UIViewController { title = NSLocalizedString("Trending Hashtags", comment: "trending hashtags screen title") - view.backgroundColor = .systemGroupedBackground + view.backgroundColor = .appGroupedBackground - let config = UICollectionLayoutListConfiguration(appearance: .grouped) + var config = UICollectionLayoutListConfiguration(appearance: .grouped) + config.backgroundColor = .appGroupedBackground + config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in + guard let item = dataSource.itemIdentifier(for: indexPath) else { + return sectionConfig + } + var config = sectionConfig + if item.hideSeparators { + config.topSeparatorVisibility = .hidden + config.bottomSeparatorVisibility = .hidden + } + return config + } let layout = UICollectionViewCompositionalLayout.list(using: config) collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] @@ -43,14 +59,24 @@ class TrendingHashtagsViewController: UIViewController { collectionView.allowsFocus = true view.addSubview(collectionView) - let registration = UICollectionView.CellRegistration { cell, indexPath, hashtag in + let loadingCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in + cell.indicator.startAnimating() + } + let hashtagCell = UICollectionView.CellRegistration { cell, indexPath, hashtag in cell.updateUI(hashtag: hashtag) } - + let confirmLoadMoreCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, isLoading in + cell.confirmLoadMore = self.confirmLoadMore + cell.isLoading = isLoading + } dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { (collectionView, indexPath, item) in switch item { - case let .tag(hashtag): - return collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: hashtag) + case .loadingIndicator: + return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) + case .tag(let hashtag): + return collectionView.dequeueConfiguredReusableCell(using: hashtagCell, for: indexPath, item: hashtag) + case .confirmLoadMore(let loading): + return collectionView.dequeueConfiguredReusableCell(using: confirmLoadMoreCell, for: indexPath, item: loading) } } } @@ -58,18 +84,104 @@ class TrendingHashtagsViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - let request = Client.getTrendingHashtags(limit: 10) Task { - guard let (hashtags, _) = try? await mastodonController.run(request) else { - return - } - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.trendingTags]) - snapshot.appendItems(hashtags.map { .tag($0) }) - await dataSource.apply(snapshot) + await loadInitial() } } + private func request(offset: Int?) -> Request<[Hashtag]> { + if mastodonController.instanceFeatures.hasMastodonVersion(3, 5, 0) { + return Client.getTrendingHashtags(offset: offset) + } else { + return Client.getTrendingHashtagsDeprecated(offset: offset) + } + } + + @MainActor + private func loadInitial() async { + guard case .unloaded = state else { + return + } + state = .loadingInitial + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.trendingTags]) + snapshot.appendItems([.loadingIndicator]) + await dataSource.apply(snapshot) + + do { + let request = self.request(offset: nil) + let (hashtags, _) = try await mastodonController.run(request) + snapshot.deleteItems([.loadingIndicator]) + snapshot.appendItems(hashtags.map { .tag($0) }) + state = .loaded + await dataSource.apply(snapshot) + } catch { + snapshot.deleteItems([.loadingIndicator]) + await dataSource.apply(snapshot) + state = .unloaded + + let config = ToastConfiguration(from: error, with: "Error Loading Trending Tags", in: self) { [weak self] toast in + toast.dismissToast(animated: true) + await self?.loadInitial() + } + self.showToast(configuration: config, animated: true) + } + } + + @MainActor + private func loadOlder() async { + guard case .loaded = state else { + return + } + state = .loadingOlder + + let origSnapshot = dataSource.snapshot() + var snapshot = origSnapshot + if Preferences.shared.disableInfiniteScrolling { + snapshot.appendItems([.confirmLoadMore(false)]) + await dataSource.apply(snapshot) + + for await _ in confirmLoadMore.values { + break + } + + snapshot.deleteItems([.confirmLoadMore(false)]) + snapshot.appendItems([.confirmLoadMore(true)]) + await dataSource.apply(snapshot, animatingDifferences: false) + } else { + snapshot.appendItems([.loadingIndicator]) + await dataSource.apply(snapshot) + } + + do { + let request = self.request(offset: snapshot.itemIdentifiers.count - 1) + let (hashtags, _) = try await mastodonController.run(request) + var snapshot = origSnapshot + snapshot.appendItems(hashtags.map { .tag($0) }) + await dataSource.apply(snapshot) + } catch { + await dataSource.apply(origSnapshot) + + let config = ToastConfiguration(from: error, with: "Error Loading Older Tags", in: self) { [weak self] toast in + toast.dismissToast(animated: true) + await self?.loadOlder() + } + self.showToast(configuration: config, animated: true) + } + + state = .loaded + } + +} + +extension TrendingHashtagsViewController { + enum State { + case unloaded + case loadingInitial + case loaded + case loadingOlder + } } extension TrendingHashtagsViewController { @@ -77,11 +189,45 @@ extension TrendingHashtagsViewController { case trendingTags } enum Item: Hashable { + case loadingIndicator case tag(Hashtag) + case confirmLoadMore(Bool) + + var hideSeparators: Bool { + switch self { + case .loadingIndicator: + return true + case .tag(_): + return false + case .confirmLoadMore(_): + return false + } + } + + var shouldSelect: Bool { + if case .tag(_) = self { + return true + } else { + return false + } + } } } extension TrendingHashtagsViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + if indexPath.section == collectionView.numberOfSections - 1, + indexPath.row == collectionView.numberOfItems(inSection: indexPath.section) - 1 { + Task { + await self.loadOlder() + } + } + } + + func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { + return dataSource.itemIdentifier(for: indexPath)?.shouldSelect ?? false + } + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath), case let .tag(hashtag) = item else { diff --git a/Tusker/Screens/Explore/TrendingLinksViewController.swift b/Tusker/Screens/Explore/TrendingLinksViewController.swift index a622eb33..1ff4324f 100644 --- a/Tusker/Screens/Explore/TrendingLinksViewController.swift +++ b/Tusker/Screens/Explore/TrendingLinksViewController.swift @@ -132,8 +132,8 @@ class TrendingLinksViewController: UIViewController, CollectionViewController { snapshot.deleteSections([.loadingIndicator]) snapshot.appendSections([.links]) snapshot.appendItems(links.map { .link($0) }) - await dataSource.apply(snapshot) state = .loaded + await dataSource.apply(snapshot) } catch { await dataSource.apply(NSDiffableDataSourceSnapshot()) state = .unloaded