From c8b2defbb8ffa47033486a94a1bcca0d737608ce Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Fri, 28 Aug 2020 15:39:17 -0700 Subject: [PATCH] Pagination --- Metatext.xcodeproj/project.pbxproj | 10 ++- Networking/Mastodon API/Endpoints/Paged.swift | 46 ++++++++++++++ .../Status List Services/ContextService.swift | 1 + .../StatusListService.swift | 3 + .../TimelineService.swift | 2 +- .../StatusListViewController.swift | 63 +++++++++++++++++++ View Models/StatusListViewModel.swift | 2 + Views/LoadingTableFooterView.swift | 23 +++++++ 8 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 Networking/Mastodon API/Endpoints/Paged.swift create mode 100644 Views/LoadingTableFooterView.swift diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 77fa3a9..fcb18a7 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ D074577A24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D074577924D29366004758DB /* URLSessionConfiguration+Extensions.swift */; }; D0A652AD24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A652AC24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift */; }; D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */; }; + D0BEB1F524F9A216001B0F04 /* Paged.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F424F9A216001B0F04 /* Paged.swift */; }; + D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */; }; D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42224F76169001EBDBB /* IdentitiesView.swift */; }; D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */; }; D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42424F76169001EBDBB /* AddIdentityView.swift */; }; @@ -194,6 +196,8 @@ D074577924D29366004758DB /* URLSessionConfiguration+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSessionConfiguration+Extensions.swift"; sourceTree = ""; }; D0A652AC24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PreferencesEndpoint+Stubbing.swift"; sourceTree = ""; }; D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = ""; }; + D0BEB1F424F9A216001B0F04 /* Paged.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Paged.swift; sourceTree = ""; }; + D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingTableFooterView.swift; sourceTree = ""; }; D0C7D41E24F76169001EBDBB /* Metatext.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Metatext.entitlements; sourceTree = ""; }; D0C7D41F24F76169001EBDBB /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D0C7D42224F76169001EBDBB /* IdentitiesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentitiesView.swift; sourceTree = ""; }; @@ -416,10 +420,11 @@ D0C7D42024F76169001EBDBB /* Views */ = { isa = PBXGroup; children = ( - D01F41E024F8885900D55A2D /* Attachments */, D0C7D42424F76169001EBDBB /* AddIdentityView.swift */, + D01F41E024F8885900D55A2D /* Attachments */, D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */, D0C7D42224F76169001EBDBB /* IdentitiesView.swift */, + D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */, D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */, D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */, D0C7D42624F76169001EBDBB /* PreferencesView.swift */, @@ -577,6 +582,7 @@ D0C7D48324F76169001EBDBB /* ContextEndpoint.swift */, D0C7D48224F76169001EBDBB /* DeletionEndpoint.swift */, D0C7D47D24F76169001EBDBB /* InstanceEndpoint.swift */, + D0BEB1F424F9A216001B0F04 /* Paged.swift */, D0C7D47C24F76169001EBDBB /* PreferencesEndpoint.swift */, D0C7D47B24F76169001EBDBB /* PushSubscriptionEndpoint.swift */, D0C7D48424F76169001EBDBB /* StatusEndpoint.swift */, @@ -860,6 +866,7 @@ D0C7D4CD24F7616A001EBDBB /* AddIdentityViewModel.swift in Sources */, D03658D124EDD80900AC17EC /* ContextEndpoint+Stubbing.swift in Sources */, D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */, + D0BEB1F524F9A216001B0F04 /* Paged.swift in Sources */, D0DC174A24CFF15F00A75C65 /* AppAuthorizationEndpoint+Stubbing.swift in Sources */, D0C7D49A24F7616A001EBDBB /* StatusListView.swift in Sources */, D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */, @@ -908,6 +915,7 @@ D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */, D0C7D4CF24F7616A001EBDBB /* StatusViewModel.swift in Sources */, D0C7D4C724F7616A001EBDBB /* PostingReadingPreferencesViewModel.swift in Sources */, + D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */, D0DC175224D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */, D0C7D4F124F7616A001EBDBB /* IdentityService.swift in Sources */, D04FD74224D4AA34007D572D /* DevelopmentModels.swift in Sources */, diff --git a/Networking/Mastodon API/Endpoints/Paged.swift b/Networking/Mastodon API/Endpoints/Paged.swift new file mode 100644 index 0000000..dd9eb99 --- /dev/null +++ b/Networking/Mastodon API/Endpoints/Paged.swift @@ -0,0 +1,46 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +struct Paged { + let endpoint: T + let maxID: String? + let minID: String? + let sinceID: String? + let limit: Int? + + init(_ endpoint: T, maxID: String? = nil, minID: String? = nil, sinceID: String? = nil, limit: Int? = nil) { + self.endpoint = endpoint + self.maxID = maxID + self.minID = minID + self.sinceID = sinceID + self.limit = limit + } +} + +extension Paged: MastodonEndpoint { + typealias ResultType = T.ResultType + + var APIVersion: String { endpoint.APIVersion } + + var context: [String] { endpoint.context } + + var pathComponentsInContext: [String] { endpoint.pathComponentsInContext } + + var method: HTTPMethod { endpoint.method } + + var encoding: ParameterEncoding { endpoint.encoding } + + var parameters: [String: Any]? { + var parameters = endpoint.parameters ?? [String: Any]() + + parameters["max_id"] = maxID + parameters["min_id"] = minID + parameters["since_id"] = sinceID + parameters["limit"] = limit + + return parameters + } + + var headers: HTTPHeaders? { endpoint.headers } +} diff --git a/Services/Status List Services/ContextService.swift b/Services/Status List Services/ContextService.swift index 0c627f6..d81acf4 100644 --- a/Services/Status List Services/ContextService.swift +++ b/Services/Status List Services/ContextService.swift @@ -5,6 +5,7 @@ import Combine struct ContextService { let statusSections: AnyPublisher<[[Status]], Error> + let paginates = false private let status: Status private let context = CurrentValueSubject(MastodonContext(ancestors: [], descendants: [])) diff --git a/Services/Status List Services/StatusListService.swift b/Services/Status List Services/StatusListService.swift index 0b6b21b..b200981 100644 --- a/Services/Status List Services/StatusListService.swift +++ b/Services/Status List Services/StatusListService.swift @@ -5,6 +5,7 @@ import Combine protocol StatusListService { var statusSections: AnyPublisher<[[Status]], Error> { get } + var paginates: Bool { get } var contextParentID: String? { get } func isPinned(status: Status) -> Bool func isReplyInContext(status: Status) -> Bool @@ -15,6 +16,8 @@ protocol StatusListService { } extension StatusListService { + var paginates: Bool { true } + var contextParentID: String? { nil } func isPinned(status: Status) -> Bool { false } diff --git a/Services/Status List Services/TimelineService.swift b/Services/Status List Services/TimelineService.swift index 11b57ea..ece183c 100644 --- a/Services/Status List Services/TimelineService.swift +++ b/Services/Status List Services/TimelineService.swift @@ -22,7 +22,7 @@ struct TimelineService { extension TimelineService: StatusListService { func request(maxID: String?, minID: String?) -> AnyPublisher { - return networkClient.request(timeline.endpoint) + networkClient.request(Paged(timeline.endpoint, maxID: maxID, minID: minID)) .map { ($0, timeline) } .flatMap(contentDatabase.insert(statuses:collection:)) .eraseToAnyPublisher() diff --git a/View Controllers/StatusListViewController.swift b/View Controllers/StatusListViewController.swift index 600b56b..6a9b0d3 100644 --- a/View Controllers/StatusListViewController.swift +++ b/View Controllers/StatusListViewController.swift @@ -5,6 +5,7 @@ import Combine class StatusListViewController: UITableViewController { private let viewModel: StatusListViewModel + private let loadingTableFooterView = LoadingTableFooterView() private var cancellables = Set() private var cellHeightCaches = [CGFloat: [String: CGFloat]]() @@ -30,6 +31,7 @@ class StatusListViewController: UITableViewController { super.init(style: .plain) } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -45,8 +47,10 @@ class StatusListViewController: UITableViewController { } tableView.dataSource = dataSource + tableView.prefetchDataSource = self tableView.cellLayoutMarginsFollowReadableWidth = true tableView.separatorInset = .zero + tableView.tableFooterView = UIView() viewModel.$statusIDs .sink { [weak self] in @@ -73,6 +77,16 @@ class StatusListViewController: UITableViewController { } } .store(in: &cancellables) + + viewModel.$loading + .receive(on: RunLoop.main) + .sink { [weak self] in + guard let self = self else { return } + + self.tableView.tableFooterView = $0 ? self.loadingTableFooterView : UIView() + self.sizeTableHeaderFooterViews() + } + .store(in: &cancellables) } override func viewWillAppear(_ animated: Bool) { @@ -114,6 +128,26 @@ class StatusListViewController: UITableViewController { StatusListViewController(viewModel: contextViewModel), animated: true) } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + sizeTableHeaderFooterViews() + } +} + +extension StatusListViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + guard + viewModel.paginates, + let indexPath = indexPaths.last, + indexPath.section == dataSource.numberOfSections(in: tableView) - 1, + indexPath.row == dataSource.tableView(tableView, numberOfRowsInSection: indexPath.section) - 1, + let maxID = dataSource.itemIdentifier(for: indexPath) + else { return } + + viewModel.request(maxID: maxID) + } } extension StatusListViewController: StatusTableViewCellDelegate { @@ -130,6 +164,35 @@ private extension StatusListViewController { present(activityViewController, animated: true, completion: nil) } + + func sizeTableHeaderFooterViews() { + // https://useyourloaf.com/blog/variable-height-table-view-header/ + if let headerView = tableView.tableHeaderView { + let size = headerView.systemLayoutSizeFitting( + CGSize(width: tableView.frame.width, height: .greatestFiniteMagnitude), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel) + + if headerView.frame.size.height != size.height { + headerView.frame.size.height = size.height + tableView.tableHeaderView = headerView + tableView.layoutIfNeeded() + } + } + + if let footerView = tableView.tableFooterView { + let size = footerView.systemLayoutSizeFitting( + CGSize(width: tableView.frame.width, height: .greatestFiniteMagnitude), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel) + + if footerView.frame.size.height != size.height { + footerView.frame.size.height = size.height + tableView.tableFooterView = footerView + tableView.layoutIfNeeded() + } + } + } } private extension Array where Element: Sequence, Element.Element: Hashable { diff --git a/View Models/StatusListViewModel.swift b/View Models/StatusListViewModel.swift index e9fa389..1453352 100644 --- a/View Models/StatusListViewModel.swift +++ b/View Models/StatusListViewModel.swift @@ -29,6 +29,8 @@ class StatusListViewModel: ObservableObject { } extension StatusListViewModel { + var paginates: Bool { statusListService.paginates } + var contextParentID: String? { statusListService.contextParentID } func request(maxID: String? = nil, minID: String? = nil) { diff --git a/Views/LoadingTableFooterView.swift b/Views/LoadingTableFooterView.swift new file mode 100644 index 0000000..7c4575c --- /dev/null +++ b/Views/LoadingTableFooterView.swift @@ -0,0 +1,23 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit + +class LoadingTableFooterView: UIView { + let activityIndicatorView = UIActivityIndicatorView() + + override init(frame: CGRect) { + super.init(frame: frame) + + addSubview(activityIndicatorView) + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true + activityIndicatorView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor).isActive = true + activityIndicatorView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor).isActive = true + activityIndicatorView.startAnimating() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +}