Compare commits
4 Commits
1fda4248ec
...
e6e5554edf
Author | SHA1 | Date | |
---|---|---|---|
e6e5554edf | |||
9026f487ec | |||
c0097ba752 | |||
f109253bba |
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class NotificationGroup {
|
public class NotificationGroup: Identifiable, Hashable {
|
||||||
public let notifications: [Notification]
|
public let notifications: [Notification]
|
||||||
public let id: String
|
public let id: String
|
||||||
public let kind: Notification.Kind
|
public let kind: Notification.Kind
|
||||||
@ -25,6 +25,14 @@ public class NotificationGroup {
|
|||||||
self.statusState = nil
|
self.statusState = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
|
||||||
|
return lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
|
||||||
|
public func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
|
||||||
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
||||||
var groups = [[Notification]]()
|
var groups = [[Notification]]()
|
||||||
@ -50,5 +58,3 @@ public class NotificationGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension NotificationGroup: Identifiable {}
|
|
||||||
|
@ -73,12 +73,12 @@ class FastAccountSwitcherViewController: UIViewController {
|
|||||||
|
|
||||||
accountView.alpha = 0
|
accountView.alpha = 0
|
||||||
accountView.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
|
accountView.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
|
||||||
UIView.addKeyframe(withRelativeStartTime: relStart, relativeDuration: relDuration) {
|
UIView.addKeyframe(withRelativeStartTime: relStart, relativeDuration: relDuration / 2) {
|
||||||
accountView.alpha = 1
|
accountView.alpha = 1
|
||||||
accountView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
|
accountView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
|
||||||
}
|
}
|
||||||
|
|
||||||
UIView.addKeyframe(withRelativeStartTime: relStart + relDuration, relativeDuration: relDuration) {
|
UIView.addKeyframe(withRelativeStartTime: relStart + relDuration / 2, relativeDuration: relDuration / 2) {
|
||||||
accountView.transform = .identity
|
accountView.transform = .identity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
class NotificationsTableViewController: TimelineLikeTableViewController<NotificationGroup> {
|
class NotificationsTableViewController: DiffableTimelineLikeTableViewController<NotificationsTableViewController.Section, NotificationGroup> {
|
||||||
|
|
||||||
private let statusCell = "statusCell"
|
private let statusCell = "statusCell"
|
||||||
private let actionGroupCell = "actionGroupCell"
|
private let actionGroupCell = "actionGroupCell"
|
||||||
@ -54,88 +54,9 @@ class NotificationsTableViewController: TimelineLikeTableViewController<Notifica
|
|||||||
tableView.register(UINib(nibName: "BasicTableViewCell", bundle: .main), forCellReuseIdentifier: unknownCell)
|
tableView.register(UINib(nibName: "BasicTableViewCell", bundle: .main), forCellReuseIdentifier: unknownCell)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func loadInitialItems(completion: @escaping ([NotificationGroup]) -> Void) {
|
// MARK: - DiffableTimelineLikeTableViewController
|
||||||
let request = Client.getNotifications(excludeTypes: excludedTypes)
|
|
||||||
mastodonController.run(request) { (response) in
|
|
||||||
guard case let .success(notifications, pagination) = response else {
|
|
||||||
completion([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let groups = NotificationGroup.createGroups(notifications: notifications, only: self.groupTypes)
|
|
||||||
|
|
||||||
self.newer = pagination?.newer
|
|
||||||
self.older = pagination?.older
|
|
||||||
|
|
||||||
self.mastodonController.persistentContainer.addAll(notifications: notifications) {
|
|
||||||
completion(groups)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func loadOlder(completion: @escaping ([NotificationGroup]) -> Void) {
|
override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ group: NotificationGroup) -> UITableViewCell? {
|
||||||
guard let older = older else {
|
|
||||||
completion([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let request = Client.getNotifications(excludeTypes: excludedTypes, range: older)
|
|
||||||
mastodonController.run(request) { (response) in
|
|
||||||
guard case let .success(newNotifications, pagination) = response else { fatalError() }
|
|
||||||
|
|
||||||
self.older = pagination?.older
|
|
||||||
|
|
||||||
let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
|
|
||||||
|
|
||||||
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
|
|
||||||
completion(groups)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func loadNewer(completion: @escaping ([NotificationGroup]) -> Void) {
|
|
||||||
guard let newer = newer else {
|
|
||||||
completion([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let request = Client.getNotifications(excludeTypes: excludedTypes, range: newer)
|
|
||||||
mastodonController.run(request) { (response) in
|
|
||||||
guard case let .success(newNotifications, pagination) = response else { fatalError() }
|
|
||||||
|
|
||||||
if let newer = pagination?.newer {
|
|
||||||
self.newer = newer
|
|
||||||
}
|
|
||||||
|
|
||||||
let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
|
|
||||||
|
|
||||||
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
|
|
||||||
completion(groups)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dismissNotificationsInGroup(at indexPath: IndexPath, completion: (() -> Void)? = nil) {
|
|
||||||
let group = DispatchGroup()
|
|
||||||
item(for: indexPath).notifications
|
|
||||||
.map { Pachyderm.Notification.dismiss(id: $0.id) }
|
|
||||||
.forEach { (request) in
|
|
||||||
group.enter()
|
|
||||||
mastodonController.run(request) { (_) in
|
|
||||||
group.leave()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
group.notify(queue: .main) {
|
|
||||||
self.sections[indexPath.section].remove(at: indexPath.row)
|
|
||||||
self.tableView.deleteRows(at: [indexPath], with: .automatic)
|
|
||||||
completion?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - UITableViewDataSource
|
|
||||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
|
||||||
let group = item(for: indexPath)
|
|
||||||
|
|
||||||
switch group.kind {
|
switch group.kind {
|
||||||
case .mention:
|
case .mention:
|
||||||
guard let notification = group.notifications.first,
|
guard let notification = group.notifications.first,
|
||||||
@ -179,6 +100,112 @@ class NotificationsTableViewController: TimelineLikeTableViewController<Notifica
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
|
||||||
|
let request = Client.getNotifications(excludeTypes: excludedTypes)
|
||||||
|
mastodonController.run(request) { (response) in
|
||||||
|
switch response {
|
||||||
|
case let .failure(error):
|
||||||
|
completion(.failure(.client(error)))
|
||||||
|
|
||||||
|
case let .success(notifications, pagination):
|
||||||
|
let groups = NotificationGroup.createGroups(notifications: notifications, only: self.groupTypes)
|
||||||
|
|
||||||
|
self.newer = pagination?.newer
|
||||||
|
self.older = pagination?.older
|
||||||
|
|
||||||
|
self.mastodonController.persistentContainer.addAll(notifications: notifications) {
|
||||||
|
var snapshot = Snapshot()
|
||||||
|
snapshot.appendSections([.notifications])
|
||||||
|
snapshot.appendItems(groups, toSection: .notifications)
|
||||||
|
completion(.success(snapshot))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
|
||||||
|
guard let older = older else {
|
||||||
|
completion(.failure(.noOlder))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = Client.getNotifications(excludeTypes: excludedTypes, range: older)
|
||||||
|
mastodonController.run(request) { (response) in
|
||||||
|
switch response {
|
||||||
|
case let .failure(error):
|
||||||
|
completion(.failure(.client(error)))
|
||||||
|
|
||||||
|
case let .success(newNotifications, pagination):
|
||||||
|
if let older = pagination?.older {
|
||||||
|
self.older = older
|
||||||
|
}
|
||||||
|
|
||||||
|
let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
|
||||||
|
|
||||||
|
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
|
||||||
|
var snapshot = currentSnapshot()
|
||||||
|
snapshot.appendItems(groups, toSection: .notifications)
|
||||||
|
completion(.success(snapshot))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
|
||||||
|
guard let newer = newer else {
|
||||||
|
completion(.failure(.noNewer))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = Client.getNotifications(excludeTypes: excludedTypes, range: newer)
|
||||||
|
mastodonController.run(request) { (response) in
|
||||||
|
switch response {
|
||||||
|
case let .failure(error):
|
||||||
|
completion(.failure(.client(error)))
|
||||||
|
|
||||||
|
case let .success(newNotifications, pagination):
|
||||||
|
guard !newNotifications.isEmpty else {
|
||||||
|
completion(.failure(.allCaughtUp))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let newer = pagination?.newer {
|
||||||
|
self.newer = newer
|
||||||
|
}
|
||||||
|
|
||||||
|
let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
|
||||||
|
|
||||||
|
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
|
||||||
|
var snapshot = currentSnapshot()
|
||||||
|
if let first = snapshot.itemIdentifiers(inSection: .notifications).first {
|
||||||
|
snapshot.insertItems(groups, beforeItem: first)
|
||||||
|
} else {
|
||||||
|
snapshot.appendItems(groups, toSection: .notifications)
|
||||||
|
}
|
||||||
|
completion(.success(snapshot))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismissNotificationsInGroup(at indexPath: IndexPath, completion: (() -> Void)? = nil) {
|
||||||
|
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
|
let group = DispatchGroup()
|
||||||
|
item.notifications
|
||||||
|
.map { Pachyderm.Notification.dismiss(id: $0.id) }
|
||||||
|
.forEach { (request) in
|
||||||
|
group.enter()
|
||||||
|
mastodonController.run(request) { (_) in
|
||||||
|
group.leave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
group.notify(queue: .main) {
|
||||||
|
var snapshot = self.dataSource.snapshot()
|
||||||
|
snapshot.deleteItems([item])
|
||||||
|
self.dataSource.apply(snapshot, completion: completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - UITableViewDelegate
|
// MARK: - UITableViewDelegate
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||||
@ -211,6 +238,12 @@ class NotificationsTableViewController: TimelineLikeTableViewController<Notifica
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension NotificationsTableViewController {
|
||||||
|
enum Section: CaseIterable, Hashable {
|
||||||
|
case notifications
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension NotificationsTableViewController: TuskerNavigationDelegate {
|
extension NotificationsTableViewController: TuskerNavigationDelegate {
|
||||||
var apiController: MastodonController { mastodonController }
|
var apiController: MastodonController { mastodonController }
|
||||||
}
|
}
|
||||||
@ -224,7 +257,8 @@ extension NotificationsTableViewController: StatusTableViewCellDelegate {
|
|||||||
extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
|
extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
|
||||||
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||||
for indexPath in indexPaths {
|
for indexPath in indexPaths {
|
||||||
for notification in item(for: indexPath).notifications {
|
guard let group = dataSource.itemIdentifier(for: indexPath) else { continue }
|
||||||
|
for notification in group.notifications {
|
||||||
ImageCache.avatars.fetchIfNotCached(notification.account.avatar)
|
ImageCache.avatars.fetchIfNotCached(notification.account.avatar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -232,7 +266,8 @@ extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
|
|||||||
|
|
||||||
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
|
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
|
||||||
for indexPath in indexPaths {
|
for indexPath in indexPaths {
|
||||||
for notification in item(for: indexPath).notifications {
|
guard let group = dataSource.itemIdentifier(for: indexPath) else { continue }
|
||||||
|
for notification in group.notifications {
|
||||||
ImageCache.avatars.cancelWithoutCallback(notification.account.avatar)
|
ImageCache.avatars.cancelWithoutCallback(notification.account.avatar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -168,7 +168,7 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func loadOlderItems(currentSnapshot: Snapshot, completion: @escaping (LoadResult) -> Void) {
|
override func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
|
||||||
guard let older = older else {
|
guard let older = older else {
|
||||||
completion(.failure(.noOlder))
|
completion(.failure(.noOlder))
|
||||||
return
|
return
|
||||||
@ -176,12 +176,12 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
|
|||||||
|
|
||||||
if #available(iOS 15.0, *),
|
if #available(iOS 15.0, *),
|
||||||
Preferences.shared.disableInfiniteScrolling && !didConfirmLoadMore {
|
Preferences.shared.disableInfiniteScrolling && !didConfirmLoadMore {
|
||||||
guard !currentSnapshot.itemIdentifiers(inSection: .footer).contains(.confirmLoadMore) else {
|
var snapshot = currentSnapshot()
|
||||||
|
guard !snapshot.itemIdentifiers(inSection: .footer).contains(.confirmLoadMore) else {
|
||||||
// todo: need something more accurate than "success"/"failure"
|
// todo: need something more accurate than "success"/"failure"
|
||||||
completion(.success(currentSnapshot))
|
completion(.success(snapshot))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var snapshot = currentSnapshot
|
|
||||||
snapshot.appendItems([.confirmLoadMore], toSection: .footer)
|
snapshot.appendItems([.confirmLoadMore], toSection: .footer)
|
||||||
self.dataSource.apply(snapshot)
|
self.dataSource.apply(snapshot)
|
||||||
completion(.success(snapshot))
|
completion(.success(snapshot))
|
||||||
@ -199,7 +199,7 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
|
|||||||
self.older = pagination?.older
|
self.older = pagination?.older
|
||||||
|
|
||||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
var snapshot = currentSnapshot
|
var snapshot = currentSnapshot()
|
||||||
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }, toSection: .statuses)
|
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }, toSection: .statuses)
|
||||||
snapshot.deleteItems([.confirmLoadMore])
|
snapshot.deleteItems([.confirmLoadMore])
|
||||||
completion(.success(snapshot))
|
completion(.success(snapshot))
|
||||||
@ -208,7 +208,7 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func loadNewerItems(currentSnapshot: Snapshot, completion: @escaping (LoadResult) -> Void) {
|
override func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
|
||||||
guard let newer = newer else {
|
guard let newer = newer else {
|
||||||
completion(.failure(.noNewer))
|
completion(.failure(.noNewer))
|
||||||
return
|
return
|
||||||
@ -221,6 +221,11 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
|
|||||||
completion(.failure(.client(error)))
|
completion(.failure(.client(error)))
|
||||||
|
|
||||||
case let .success(statuses, pagination):
|
case let .success(statuses, pagination):
|
||||||
|
guard !statuses.isEmpty else {
|
||||||
|
completion(.failure(.allCaughtUp))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// if there are no new statuses, pagination is nil
|
// if there are no new statuses, pagination is nil
|
||||||
// if we were to then overwrite self.newer, future refresh would fail
|
// if we were to then overwrite self.newer, future refresh would fail
|
||||||
if let newer = pagination?.newer {
|
if let newer = pagination?.newer {
|
||||||
@ -228,7 +233,7 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
var snapshot = currentSnapshot
|
var snapshot = currentSnapshot()
|
||||||
let newIdentifiers = statuses.map { Item.status(id: $0.id, state: .unknown) }
|
let newIdentifiers = statuses.map { Item.status(id: $0.id, state: .unknown) }
|
||||||
if let first = snapshot.itemIdentifiers(inSection: .statuses).first {
|
if let first = snapshot.itemIdentifiers(inSection: .statuses).first {
|
||||||
snapshot.insertItems(newIdentifiers, beforeItem: first)
|
snapshot.insertItems(newIdentifiers, beforeItem: first)
|
||||||
|
@ -146,7 +146,7 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
|
|||||||
|
|
||||||
state = .loadingOlder
|
state = .loadingOlder
|
||||||
|
|
||||||
loadOlderItems(currentSnapshot: dataSource.snapshot()) { result in
|
loadOlderItems(currentSnapshot: dataSource.snapshot) { result in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.state = .loaded
|
self.state = .loaded
|
||||||
|
|
||||||
@ -212,18 +212,22 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
|
|||||||
|
|
||||||
state = .loadingNewer
|
state = .loadingNewer
|
||||||
|
|
||||||
let snapshot = dataSource.snapshot()
|
var firstItem: Item? = nil
|
||||||
|
let currentSnapshot: () -> Snapshot = {
|
||||||
var item: Item? = nil
|
let snapshot = self.dataSource.snapshot()
|
||||||
for section in timelineContentSections() {
|
|
||||||
if snapshot.indexOfSection(section) != nil,
|
for section in self.timelineContentSections() {
|
||||||
let first = snapshot.itemIdentifiers(inSection: section).first {
|
if snapshot.indexOfSection(section) != nil,
|
||||||
item = first
|
let first = snapshot.itemIdentifiers(inSection: section).first {
|
||||||
break
|
firstItem = first
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
loadNewerItems(currentSnapshot: snapshot) { result in
|
loadNewerItems(currentSnapshot: currentSnapshot) { result in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.refreshControl?.endRefreshing()
|
self.refreshControl?.endRefreshing()
|
||||||
self.state = .loaded
|
self.state = .loaded
|
||||||
@ -231,8 +235,8 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
|
|||||||
switch result {
|
switch result {
|
||||||
case let .success(snapshot):
|
case let .success(snapshot):
|
||||||
self.dataSource.apply(snapshot, animatingDifferences: false)
|
self.dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
if let item = item,
|
if let firstItem = firstItem,
|
||||||
let indexPath = self.dataSource.indexPath(for: item) {
|
let indexPath = self.dataSource.indexPath(for: firstItem) {
|
||||||
// maintain the current position in the list (don't scroll to top)
|
// maintain the current position in the list (don't scroll to top)
|
||||||
self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
|
self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
|
||||||
}
|
}
|
||||||
@ -248,6 +252,15 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
|
|||||||
}
|
}
|
||||||
self.showToast(configuration: config, animated: true)
|
self.showToast(configuration: config, animated: true)
|
||||||
|
|
||||||
|
case .failure(.allCaughtUp):
|
||||||
|
var config = ToastConfiguration(title: "You're all caught up")
|
||||||
|
config.edge = .top
|
||||||
|
config.dismissAutomaticallyAfter = 2
|
||||||
|
config.action = { (toast) in
|
||||||
|
toast.dismissToast(animated: true)
|
||||||
|
}
|
||||||
|
self.showToast(configuration: config, animated: true)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -265,11 +278,11 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
|
|||||||
fatalError("loadInitialItems(completion:) must be implemented by subclasses")
|
fatalError("loadInitialItems(completion:) must be implemented by subclasses")
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadOlderItems(currentSnapshot: Snapshot, completion: @escaping (LoadResult) -> Void) {
|
func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
|
||||||
fatalError("loadOlderItesm(completion:) must be implemented by subclasses")
|
fatalError("loadOlderItesm(completion:) must be implemented by subclasses")
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadNewerItems(currentSnapshot: Snapshot, completion: @escaping (LoadResult) -> Void) {
|
func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
|
||||||
fatalError("loadNewerItems(completion:) must be implemented by subclasses")
|
fatalError("loadNewerItems(completion:) must be implemented by subclasses")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -297,6 +310,7 @@ extension DiffableTimelineLikeTableViewController {
|
|||||||
case noClient
|
case noClient
|
||||||
case noOlder
|
case noOlder
|
||||||
case noNewer
|
case noNewer
|
||||||
|
case allCaughtUp
|
||||||
case client(Client.Error)
|
case client(Client.Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ import UIKit
|
|||||||
|
|
||||||
struct ToastConfiguration {
|
struct ToastConfiguration {
|
||||||
var systemImageName: String?
|
var systemImageName: String?
|
||||||
|
var titleFont: UIFont = .boldSystemFont(ofSize: 14)
|
||||||
var title: String
|
var title: String
|
||||||
var subtitle: String?
|
var subtitle: String?
|
||||||
var actionTitle: String?
|
var actionTitle: String?
|
||||||
@ -17,6 +18,7 @@ struct ToastConfiguration {
|
|||||||
var edgeSpacing: CGFloat = 8
|
var edgeSpacing: CGFloat = 8
|
||||||
var edge: Edge = .automatic
|
var edge: Edge = .automatic
|
||||||
var dismissOnScroll = true
|
var dismissOnScroll = true
|
||||||
|
var dismissAutomaticallyAfter: TimeInterval? = nil
|
||||||
|
|
||||||
init(title: String) {
|
init(title: String) {
|
||||||
self.title = title
|
self.title = title
|
||||||
|
@ -14,6 +14,8 @@ class ToastView: UIView {
|
|||||||
|
|
||||||
private var shrinkAnimator: UIViewPropertyAnimator?
|
private var shrinkAnimator: UIViewPropertyAnimator?
|
||||||
private var recognizedGesture = false
|
private var recognizedGesture = false
|
||||||
|
private var shouldDismissOnScroll = false
|
||||||
|
private(set) var shouldDismissAutomatically = true
|
||||||
|
|
||||||
private var offscreenTranslation: CGFloat {
|
private var offscreenTranslation: CGFloat {
|
||||||
var translation = bounds.height + configuration.edgeSpacing
|
var translation = bounds.height + configuration.edgeSpacing
|
||||||
@ -62,7 +64,7 @@ class ToastView: UIView {
|
|||||||
let titleLabel = UILabel()
|
let titleLabel = UILabel()
|
||||||
titleLabel.text = configuration.title
|
titleLabel.text = configuration.title
|
||||||
titleLabel.textColor = .white
|
titleLabel.textColor = .white
|
||||||
titleLabel.font = .boldSystemFont(ofSize: 14)
|
titleLabel.font = configuration.titleFont
|
||||||
titleLabel.adjustsFontSizeToFitWidth = true
|
titleLabel.adjustsFontSizeToFitWidth = true
|
||||||
|
|
||||||
if let subtitle = configuration.subtitle {
|
if let subtitle = configuration.subtitle {
|
||||||
@ -109,10 +111,40 @@ class ToastView: UIView {
|
|||||||
layer.shadowPath = CGPath(roundedRect: bounds, cornerWidth: layer.cornerRadius, cornerHeight: layer.cornerRadius, transform: nil)
|
layer.shadowPath = CGPath(roundedRect: bounds, cornerWidth: layer.cornerRadius, cornerHeight: layer.cornerRadius, transform: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dismissToast(animated: Bool) {
|
||||||
|
guard animated else {
|
||||||
|
removeFromSuperview()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) {
|
||||||
|
self.transform = CGAffineTransform(translationX: 0, y: self.offscreenTranslation)
|
||||||
|
} completion: { (_) in
|
||||||
|
self.removeFromSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func animateAppearance() {
|
||||||
|
self.transform = CGAffineTransform(translationX: 0, y: offscreenTranslation)
|
||||||
|
let duration = 0.5
|
||||||
|
let velocity = 0.5
|
||||||
|
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: velocity, options: []) {
|
||||||
|
self.transform = .identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupDismissOnScroll(connectedTo scrollView: UIScrollView) {
|
||||||
|
guard configuration.dismissOnScroll else { return }
|
||||||
|
scrollView.panGestureRecognizer.addTarget(self, action: #selector(scrollViewPanGestureRecognized))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Interaction
|
||||||
|
|
||||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
super.touchesBegan(touches, with: event)
|
super.touchesBegan(touches, with: event)
|
||||||
|
|
||||||
recognizedGesture = false
|
recognizedGesture = false
|
||||||
|
shouldDismissAutomatically = false
|
||||||
|
|
||||||
shrinkAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut) {
|
shrinkAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut) {
|
||||||
self.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
|
self.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
|
||||||
}
|
}
|
||||||
@ -145,6 +177,7 @@ class ToastView: UIView {
|
|||||||
switch recognizer.state {
|
switch recognizer.state {
|
||||||
case .began:
|
case .began:
|
||||||
recognizedGesture = true
|
recognizedGesture = true
|
||||||
|
shouldDismissAutomatically = false
|
||||||
UIView.animate(withDuration: 0.1, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) {
|
UIView.animate(withDuration: 0.1, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) {
|
||||||
self.transform = .identity
|
self.transform = .identity
|
||||||
}
|
}
|
||||||
@ -199,24 +232,23 @@ class ToastView: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func dismissToast(animated: Bool) {
|
@objc private func scrollViewPanGestureRecognized(_ recognizer: UIPanGestureRecognizer) {
|
||||||
guard animated else {
|
switch recognizer.state {
|
||||||
removeFromSuperview()
|
case .began:
|
||||||
return
|
shouldDismissOnScroll = true
|
||||||
}
|
|
||||||
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) {
|
case .changed:
|
||||||
self.transform = CGAffineTransform(translationX: 0, y: self.offscreenTranslation)
|
let translation = recognizer.translation(in: recognizer.view).y
|
||||||
} completion: { (_) in
|
if shouldDismissOnScroll && abs(translation) > 50 {
|
||||||
self.removeFromSuperview()
|
dismissToast(animated: true)
|
||||||
}
|
shouldDismissOnScroll = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func animateAppearance() {
|
case .ended, .cancelled:
|
||||||
self.transform = CGAffineTransform(translationX: 0, y: offscreenTranslation)
|
shouldDismissOnScroll = false
|
||||||
let duration = 0.5
|
|
||||||
let velocity = 0.5
|
default:
|
||||||
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: velocity, options: []) {
|
break
|
||||||
self.transform = .identity
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,6 +11,7 @@ import UIKit
|
|||||||
protocol ToastableViewController: UIViewController {
|
protocol ToastableViewController: UIViewController {
|
||||||
|
|
||||||
var toastParentView: UIView { get }
|
var toastParentView: UIView { get }
|
||||||
|
var toastScrollView: UIScrollView? { get }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,6 +34,7 @@ extension ToastableViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var toastParentView: UIView { view }
|
var toastParentView: UIView { view }
|
||||||
|
var toastScrollView: UIScrollView? { view as? UIScrollView }
|
||||||
|
|
||||||
func showToast(configuration config: ToastConfiguration, animated: Bool) {
|
func showToast(configuration config: ToastConfiguration, animated: Bool) {
|
||||||
currentToast?.dismissToast(animated: false)
|
currentToast?.dismissToast(animated: false)
|
||||||
@ -67,6 +69,18 @@ extension ToastableViewController {
|
|||||||
if animated {
|
if animated {
|
||||||
toast.animateAppearance()
|
toast.animateAppearance()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.dismissOnScroll,
|
||||||
|
let scrollView = toastScrollView {
|
||||||
|
toast.setupDismissOnScroll(connectedTo: scrollView)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let time = config.dismissAutomaticallyAfter {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + time) { [weak toast] in
|
||||||
|
guard let toast = toast, toast.shouldDismissAutomatically else { return }
|
||||||
|
toast.dismissToast(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func effectiveEdge(edge: ToastConfiguration.Edge) -> ToastConfiguration.Edge {
|
private func effectiveEdge(edge: ToastConfiguration.Edge) -> ToastConfiguration.Edge {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user