Add infinite scrolling to trending hashtags screen

See #355
This commit is contained in:
Shadowfacts 2023-02-11 18:21:40 -05:00
parent b63f663947
commit 841119949b
3 changed files with 179 additions and 21 deletions

View File

@ -417,16 +417,28 @@ public class Client {
} }
// MARK: - Instance // MARK: - Instance
public static func getTrendingHashtags(limit: Int? = nil) -> Request<[Hashtag]> { public static func getTrendingHashtagsDeprecated(limit: Int? = nil, offset: Int? = nil) -> Request<[Hashtag]> {
let parameters: [Parameter] var parameters: [Parameter] = []
if let limit = limit { if let limit {
parameters = ["limit" => limit] parameters.append("limit" => limit)
} else { }
parameters = [] if let offset {
parameters.append("offset" => offset)
} }
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends", queryParameters: parameters) 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]> { public static func getTrendingStatuses(limit: Int? = nil) -> Request<[Status]> {
let parameters: [Parameter] let parameters: [Parameter]
if let limit = limit { if let limit = limit {

View File

@ -9,6 +9,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import WebURLFoundationExtras import WebURLFoundationExtras
import Combine
class TrendingHashtagsViewController: UIViewController { class TrendingHashtagsViewController: UIViewController {
@ -17,6 +18,9 @@ class TrendingHashtagsViewController: UIViewController {
private var collectionView: UICollectionView! private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var state = State.unloaded
private var confirmLoadMore = PassthroughSubject<Void, Never>()
init(mastodonController: MastodonController) { init(mastodonController: MastodonController) {
self.mastodonController = mastodonController self.mastodonController = mastodonController
@ -32,9 +36,21 @@ class TrendingHashtagsViewController: UIViewController {
title = NSLocalizedString("Trending Hashtags", comment: "trending hashtags screen title") 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) let layout = UICollectionViewCompositionalLayout.list(using: config)
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
@ -43,14 +59,24 @@ class TrendingHashtagsViewController: UIViewController {
collectionView.allowsFocus = true collectionView.allowsFocus = true
view.addSubview(collectionView) view.addSubview(collectionView)
let registration = UICollectionView.CellRegistration<TrendingHashtagCollectionViewCell, Hashtag> { cell, indexPath, hashtag in let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, itemIdentifier in
cell.indicator.startAnimating()
}
let hashtagCell = UICollectionView.CellRegistration<TrendingHashtagCollectionViewCell, Hashtag> { cell, indexPath, hashtag in
cell.updateUI(hashtag: hashtag) cell.updateUI(hashtag: hashtag)
} }
let confirmLoadMoreCell = UICollectionView.CellRegistration<ConfirmLoadMoreCollectionViewCell, Bool> { [unowned self] cell, indexPath, isLoading in
cell.confirmLoadMore = self.confirmLoadMore
cell.isLoading = isLoading
}
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { (collectionView, indexPath, item) in dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { (collectionView, indexPath, item) in
switch item { switch item {
case let .tag(hashtag): case .loadingIndicator:
return collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: hashtag) 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) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
let request = Client.getTrendingHashtags(limit: 10)
Task { Task {
guard let (hashtags, _) = try? await mastodonController.run(request) else { await loadInitial()
return
}
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.trendingTags])
snapshot.appendItems(hashtags.map { .tag($0) })
await dataSource.apply(snapshot)
} }
} }
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<Section, Item>()
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 { extension TrendingHashtagsViewController {
@ -77,11 +189,45 @@ extension TrendingHashtagsViewController {
case trendingTags case trendingTags
} }
enum Item: Hashable { enum Item: Hashable {
case loadingIndicator
case tag(Hashtag) 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 { 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) { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath), guard let item = dataSource.itemIdentifier(for: indexPath),
case let .tag(hashtag) = item else { case let .tag(hashtag) = item else {

View File

@ -132,8 +132,8 @@ class TrendingLinksViewController: UIViewController, CollectionViewController {
snapshot.deleteSections([.loadingIndicator]) snapshot.deleteSections([.loadingIndicator])
snapshot.appendSections([.links]) snapshot.appendSections([.links])
snapshot.appendItems(links.map { .link($0) }) snapshot.appendItems(links.map { .link($0) })
await dataSource.apply(snapshot)
state = .loaded state = .loaded
await dataSource.apply(snapshot)
} catch { } catch {
await dataSource.apply(NSDiffableDataSourceSnapshot()) await dataSource.apply(NSDiffableDataSourceSnapshot())
state = .unloaded state = .unloaded