Add loading indicator to DiffableTimelineLikeTableViewController

This commit is contained in:
Shadowfacts 2022-09-12 21:52:10 -04:00
parent 8b78a5e7ad
commit bbfb3b0a7a
6 changed files with 157 additions and 50 deletions

View File

@ -50,6 +50,7 @@
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A5402635FB3C0095BD04 /* PollOptionView.swift */; };
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A542263634100095BD04 /* PollOptionCheckboxView.swift */; };
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */; };
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */; };
D626493523BD94CE00612E6E /* CompositionAttachmentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */; };
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626493623C0FD0000612E6E /* AllPhotosTableViewCell.swift */; };
D626493923C0FD0000612E6E /* AllPhotosTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D626493723C0FD0000612E6E /* AllPhotosTableViewCell.xib */; };
@ -397,6 +398,7 @@
D623A5402635FB3C0095BD04 /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = "<group>"; };
D623A542263634100095BD04 /* PollOptionCheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionCheckboxView.swift; sourceTree = "<group>"; };
D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableTableViewCell.swift; sourceTree = "<group>"; };
D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingTableViewCell.swift; sourceTree = "<group>"; };
D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachmentData.swift; sourceTree = "<group>"; };
D626493623C0FD0000612E6E /* AllPhotosTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllPhotosTableViewCell.swift; sourceTree = "<group>"; };
D626493723C0FD0000612E6E /* AllPhotosTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AllPhotosTableViewCell.xib; sourceTree = "<group>"; };
@ -1268,6 +1270,7 @@
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */,
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */,
D620483323D3801D008A63EF /* LinkTextView.swift */,
D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */,
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
@ -1918,6 +1921,7 @@
D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */,
D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */,
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */,
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */,
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */,
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,

View File

@ -9,7 +9,7 @@
import UIKit
import Pachyderm
class NotificationsTableViewController: DiffableTimelineLikeTableViewController<NotificationsTableViewController.Section, NotificationGroup> {
class NotificationsTableViewController: DiffableTimelineLikeTableViewController<NotificationsTableViewController.Section, NotificationsTableViewController.Item> {
private let statusCell = "statusCell"
private let actionGroupCell = "actionGroupCell"
@ -56,7 +56,12 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
// MARK: - DiffableTimelineLikeTableViewController
override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ group: NotificationGroup) -> UITableViewCell? {
override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? {
if case .loadingIndicator = item {
return self.loadingIndicatorCell(indexPath: indexPath)
}
let group = item.group!
switch group.kind {
case .mention:
guard let notification = group.notifications.first,
@ -118,7 +123,7 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
self.mastodonController.persistentContainer.addAll(notifications: notifications) {
var snapshot = Snapshot()
snapshot.appendSections([.notifications])
snapshot.appendItems(groups, toSection: .notifications)
snapshot.appendItems(groups.map { .notificationGroup($0) }, toSection: .notifications)
completion(.success(snapshot))
}
}
@ -145,11 +150,11 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
let olderGroups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
let existingGroups = currentSnapshot().itemIdentifiers
let existingGroups = currentSnapshot().itemIdentifiers.compactMap(\.group)
let merged = NotificationGroup.mergeGroups(first: existingGroups, second: olderGroups, only: self.groupTypes)
var snapshot = NSDiffableDataSourceSnapshot<Section, NotificationGroup>()
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.notifications])
snapshot.appendItems(merged, toSection: .notifications)
snapshot.appendItems(merged.map { .notificationGroup($0) }, toSection: .notifications)
completion(.success(snapshot))
}
}
@ -179,11 +184,11 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
let newerGroups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
let existingGroups = currentSnapshot().itemIdentifiers
let existingGroups = currentSnapshot().itemIdentifiers.compactMap(\.group)
let merged = NotificationGroup.mergeGroups(first: newerGroups, second: existingGroups, only: self.groupTypes)
var snapshot = NSDiffableDataSourceSnapshot<Section, NotificationGroup>()
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.notifications])
snapshot.appendItems(merged, toSection: .notifications)
snapshot.appendItems(merged.map { .notificationGroup($0) }, toSection: .notifications)
completion(.success(snapshot))
}
}
@ -191,9 +196,12 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
}
private func dismissNotificationsInGroup(at indexPath: IndexPath, completion: (() -> Void)? = nil) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
guard let item = dataSource.itemIdentifier(for: indexPath),
let notifications = item.group?.notifications else {
return
}
let group = DispatchGroup()
item.notifications
notifications
.map { Pachyderm.Notification.dismiss(id: $0.id) }
.forEach { (request) in
group.enter()
@ -241,9 +249,23 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
}
extension NotificationsTableViewController {
enum Section: CaseIterable, Hashable {
enum Section: DiffableTimelineLikeSection {
case loadingIndicator
case notifications
}
enum Item: DiffableTimelineLikeItem {
case loadingIndicator
case notificationGroup(NotificationGroup)
var group: NotificationGroup? {
switch self {
case .loadingIndicator:
return nil
case .notificationGroup(let group):
return group
}
}
}
}
extension NotificationsTableViewController: TuskerNavigationDelegate {
@ -265,7 +287,7 @@ extension NotificationsTableViewController: StatusTableViewCellDelegate {
extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let group = dataSource.itemIdentifier(for: indexPath) else { continue }
guard let group = dataSource.itemIdentifier(for: indexPath)?.group else { continue }
for notification in group.notifications {
guard let avatar = notification.account.avatar else { continue }
ImageCache.avatars.fetchIfNotCached(avatar)
@ -275,7 +297,7 @@ extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let group = dataSource.itemIdentifier(for: indexPath) else { continue }
guard let group = dataSource.itemIdentifier(for: indexPath)?.group else { continue }
for notification in group.notifications {
guard let avatar = notification.account.avatar else { continue }
ImageCache.avatars.cancelWithoutCallback(avatar)

View File

@ -60,14 +60,17 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
// MARK: - DiffableTimelineLikeTableViewController
override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? {
let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell
switch item {
case .loadingIndicator:
return self.loadingIndicatorCell(indexPath: indexPath)
cell.delegate = self
// todo: dataSource.sectionIdentifier is only available on iOS 15
cell.showPinned = dataSource.snapshot().indexOfSection(.pinned) == indexPath.section
cell.updateUI(statusID: item.id, state: item.state)
return cell
case let .status(id: id, state: state, pinned: pinned):
let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell
cell.delegate = self
cell.showPinned = pinned
cell.updateUI(statusID: id, state: state)
return cell
}
}
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
@ -94,7 +97,7 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
DispatchQueue.main.async {
var snapshot = self.dataSource.snapshot()
snapshot.appendItems(statuses.map { Item(id: $0.id, state: .unknown, pinned: false) }, toSection: .statuses)
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, pinned: false) }, toSection: .statuses)
if self.kind == .statuses {
self.loadPinnedStatuses(snapshot: { snapshot }, completion: completion)
} else {
@ -122,7 +125,7 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
DispatchQueue.main.async {
var snapshot = snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .pinned))
snapshot.appendItems(statuses.map { Item(id: $0.id, state: .unknown, pinned: true) }, toSection: .pinned)
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, pinned: true) }, toSection: .pinned)
completion(.success(snapshot))
}
}
@ -151,7 +154,7 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
var snapshot = currentSnapshot()
snapshot.appendItems(statuses.map { Item(id: $0.id, state: .unknown, pinned: false) }, toSection: .statuses)
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, pinned: false) }, toSection: .statuses)
completion(.success(snapshot))
}
}
@ -180,7 +183,7 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<Pro
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
var snapshot = currentSnapshot()
let items = statuses.map { Item(id: $0.id, state: .unknown, pinned: false) }
let items = statuses.map { Item.status(id: $0.id, state: .unknown, pinned: false) }
if let first = snapshot.itemIdentifiers(inSection: .statuses).first {
snapshot.insertItems(items, beforeItem: first)
} else {
@ -239,22 +242,22 @@ extension ProfileStatusesViewController {
}
extension ProfileStatusesViewController {
enum Section: CaseIterable {
enum Section: DiffableTimelineLikeSection {
case loadingIndicator
case pinned
case statuses
}
struct Item: Hashable {
let id: String
let state: StatusState
let pinned: Bool
enum Item: DiffableTimelineLikeItem {
case loadingIndicator
case status(id: String, state: StatusState, pinned: Bool)
static func ==(lhs: Item, rhs: Item) -> Bool {
return lhs.id == rhs.id && lhs.pinned == rhs.pinned
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(pinned)
var id: String? {
switch self {
case .loadingIndicator:
return nil
case .status(id: let id, state: _, pinned: _):
return id
}
}
}
}

View File

@ -97,6 +97,9 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? {
switch item {
case .loadingIndicator:
return self.loadingIndicatorCell(indexPath: indexPath)
case let .status(id: id, state: state):
let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell
@ -148,6 +151,9 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
DispatchQueue.main.async {
var snapshot = self.dataSource.snapshot()
if snapshot.sectionIdentifiers.contains(.loadingIndicator) {
snapshot.deleteSections([.loadingIndicator])
}
snapshot.deleteSections([.statuses, .footer])
snapshot.appendSections([.statuses, .footer])
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }, toSection: .statuses)
@ -245,12 +251,14 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
}
extension TimelineTableViewController {
enum Section: Hashable, CaseIterable {
enum Section: DiffableTimelineLikeSection {
case loadingIndicator
case header
case statuses
case footer
}
enum Item: Hashable {
enum Item: DiffableTimelineLikeItem {
case loadingIndicator
case status(id: String, state: StatusState)
case confirmLoadMore
case publicTimelineDescription(local: Bool)
@ -270,13 +278,15 @@ extension TimelineTableViewController {
func hash(into hasher: inout Hasher) {
switch self {
case let .status(id: id, state: _):
case .loadingIndicator:
hasher.combine(0)
case let .status(id: id, state: _):
hasher.combine(1)
hasher.combine(id)
case .confirmLoadMore:
hasher.combine(1)
case let .publicTimelineDescription(local: local):
hasher.combine(2)
case let .publicTimelineDescription(local: local):
hasher.combine(3)
hasher.combine(local)
}
}

View File

@ -9,7 +9,14 @@
import UIKit
import Pachyderm
class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable, Item: Hashable>: EnhancedTableViewController, RefreshableViewController {
protocol DiffableTimelineLikeSection: Hashable, CaseIterable {
static var loadingIndicator: Self { get }
}
protocol DiffableTimelineLikeItem: Hashable {
static var loadingIndicator: Self { get }
}
class DiffableTimelineLikeTableViewController<Section: DiffableTimelineLikeSection, Item: DiffableTimelineLikeItem>: EnhancedTableViewController, RefreshableViewController {
typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item>
typealias LoadResult = Result<Snapshot, LoadError>
@ -40,6 +47,7 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 140
tableView.register(LoadingTableViewCell.self, forCellReuseIdentifier: "loadingCell")
#if !targetEnvironment(macCatalyst)
self.refreshControl = UIRefreshControl()
@ -104,15 +112,34 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
dataSource.apply(snapshot, animatingDifferences: false)
}
private func showLoadingIndicatorDelayed() -> DispatchWorkItem {
let workItem = DispatchWorkItem { [weak self] in
guard let self = self else { return }
var snapshot = self.dataSource.snapshot()
snapshot.appendSections([.loadingIndicator])
snapshot.appendItems([.loadingIndicator])
self.dataSource.apply(snapshot, animatingDifferences: false)
}
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: workItem)
return workItem
}
private func loadInitial() {
guard state == .unloaded else { return }
// set loaded immediately so we don't trigger another request while the current one is running
state = .loadingInitial
let showIndicator = showLoadingIndicatorDelayed()
loadInitialItems() { result in
DispatchQueue.main.async {
showIndicator.cancel()
switch result {
case let .success(snapshot):
case .success(var snapshot):
if snapshot.sectionIdentifiers.contains(.loadingIndicator) {
snapshot.deleteSections([.loadingIndicator])
}
self.dataSource.apply(snapshot, animatingDifferences: false)
self.state = .loaded
@ -137,16 +164,22 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
}
func loadOlder() {
guard state != .loadingOlder else { return }
guard state == .loaded else { return }
state = .loadingOlder
let showIndicator = showLoadingIndicatorDelayed()
loadOlderItems(currentSnapshot: dataSource.snapshot) { result in
DispatchQueue.main.async {
self.state = .loaded
showIndicator.cancel()
switch result {
case let .success(snapshot):
case .success(var snapshot):
if snapshot.sectionIdentifiers.contains(.loadingIndicator) {
snapshot.deleteSections([.loadingIndicator])
}
self.dataSource.apply(snapshot, animatingDifferences: false)
case let .failure(.client(error)):
@ -263,6 +296,12 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
// MARK: - Subclass Methods
func loadingIndicatorCell(indexPath: IndexPath) -> UITableViewCell? {
let cell = tableView.dequeueReusableCell(withIdentifier: "loadingCell", for: indexPath) as! LoadingTableViewCell
cell.indicator.startAnimating()
return cell
}
func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? {
fatalError("cellProvider(_:_:_:) must be implemented by subclasses")
}

View File

@ -0,0 +1,29 @@
//
// LoadingTableViewCell.swift
// Tusker
//
// Created by Shadowfacts on 9/12/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
class LoadingTableViewCell: UITableViewCell {
let indicator = UIActivityIndicatorView(style: .medium)
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
indicator.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(indicator)
NSLayoutConstraint.activate([
indicator.centerXAnchor.constraint(equalTo: centerXAnchor),
indicator.topAnchor.constraint(equalTo: topAnchor),
indicator.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}