From bbfb3b0a7ac0eca0ca0d6056f60d92a11bfd86f0 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 12 Sep 2022 21:52:10 -0400 Subject: [PATCH] Add loading indicator to DiffableTimelineLikeTableViewController --- Tusker.xcodeproj/project.pbxproj | 4 ++ .../NotificationsTableViewController.swift | 50 ++++++++++++----- .../ProfileStatusesViewController.swift | 51 +++++++++--------- .../TimelineTableViewController.swift | 20 +++++-- ...fableTimelineLikeTableViewController.swift | 53 ++++++++++++++++--- Tusker/Views/LoadingTableViewCell.swift | 29 ++++++++++ 6 files changed, 157 insertions(+), 50 deletions(-) create mode 100644 Tusker/Views/LoadingTableViewCell.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 3720de65..ebb7b1c8 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -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 = ""; }; D623A542263634100095BD04 /* PollOptionCheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionCheckboxView.swift; sourceTree = ""; }; D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableTableViewCell.swift; sourceTree = ""; }; + D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingTableViewCell.swift; sourceTree = ""; }; D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachmentData.swift; sourceTree = ""; }; D626493623C0FD0000612E6E /* AllPhotosTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllPhotosTableViewCell.swift; sourceTree = ""; }; D626493723C0FD0000612E6E /* AllPhotosTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AllPhotosTableViewCell.xib; sourceTree = ""; }; @@ -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 */, diff --git a/Tusker/Screens/Notifications/NotificationsTableViewController.swift b/Tusker/Screens/Notifications/NotificationsTableViewController.swift index c45c391c..81564d43 100644 --- a/Tusker/Screens/Notifications/NotificationsTableViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsTableViewController.swift @@ -9,7 +9,7 @@ import UIKit import Pachyderm -class NotificationsTableViewController: DiffableTimelineLikeTableViewController { +class NotificationsTableViewController: DiffableTimelineLikeTableViewController { 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() + var snapshot = NSDiffableDataSourceSnapshot() 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() + var snapshot = NSDiffableDataSourceSnapshot() 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) diff --git a/Tusker/Screens/Profile/ProfileStatusesViewController.swift b/Tusker/Screens/Profile/ProfileStatusesViewController.swift index 8adc972e..d5df51e4 100644 --- a/Tusker/Screens/Profile/ProfileStatusesViewController.swift +++ b/Tusker/Screens/Profile/ProfileStatusesViewController.swift @@ -60,14 +60,17 @@ class ProfileStatusesViewController: DiffableTimelineLikeTableViewController UITableViewCell? { - let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell - - 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 + switch item { + case .loadingIndicator: + return self.loadingIndicatorCell(indexPath: indexPath) + + 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 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 + } } } } diff --git a/Tusker/Screens/Timeline/TimelineTableViewController.swift b/Tusker/Screens/Timeline/TimelineTableViewController.swift index 3b117abf..876f0a9d 100644 --- a/Tusker/Screens/Timeline/TimelineTableViewController.swift +++ b/Tusker/Screens/Timeline/TimelineTableViewController.swift @@ -97,6 +97,9 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController 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: EnhancedTableViewController, RefreshableViewController { +protocol DiffableTimelineLikeSection: Hashable, CaseIterable { + static var loadingIndicator: Self { get } +} +protocol DiffableTimelineLikeItem: Hashable { + static var loadingIndicator: Self { get } +} + +class DiffableTimelineLikeTableViewController: EnhancedTableViewController, RefreshableViewController { typealias Snapshot = NSDiffableDataSourceSnapshot typealias LoadResult = Result @@ -40,6 +47,7 @@ class DiffableTimelineLikeTableViewController 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,25 +164,31 @@ class DiffableTimelineLikeTableViewController 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") } diff --git a/Tusker/Views/LoadingTableViewCell.swift b/Tusker/Views/LoadingTableViewCell.swift new file mode 100644 index 00000000..c4ede730 --- /dev/null +++ b/Tusker/Views/LoadingTableViewCell.swift @@ -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") + } +}