Pagination

This commit is contained in:
Justin Mazzocchi 2020-08-28 15:39:17 -07:00
parent 6e8db9586f
commit c8b2defbb8
No known key found for this signature in database
GPG Key ID: E223E6937AAFB01C
8 changed files with 148 additions and 2 deletions

View File

@ -27,6 +27,8 @@
D074577A24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D074577924D29366004758DB /* URLSessionConfiguration+Extensions.swift */; }; 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 */; }; D0A652AD24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A652AC24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift */; };
D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F224F8EE8C001B0F04 /* AttachmentView.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 */; }; D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42224F76169001EBDBB /* IdentitiesView.swift */; };
D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */; }; D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */; };
D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42424F76169001EBDBB /* AddIdentityView.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 = "<group>"; }; D074577924D29366004758DB /* URLSessionConfiguration+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSessionConfiguration+Extensions.swift"; sourceTree = "<group>"; };
D0A652AC24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PreferencesEndpoint+Stubbing.swift"; sourceTree = "<group>"; }; D0A652AC24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PreferencesEndpoint+Stubbing.swift"; sourceTree = "<group>"; };
D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = "<group>"; }; D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = "<group>"; };
D0BEB1F424F9A216001B0F04 /* Paged.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Paged.swift; sourceTree = "<group>"; };
D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingTableFooterView.swift; sourceTree = "<group>"; };
D0C7D41E24F76169001EBDBB /* Metatext.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Metatext.entitlements; sourceTree = "<group>"; }; D0C7D41E24F76169001EBDBB /* Metatext.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Metatext.entitlements; sourceTree = "<group>"; };
D0C7D41F24F76169001EBDBB /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; D0C7D41F24F76169001EBDBB /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D0C7D42224F76169001EBDBB /* IdentitiesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentitiesView.swift; sourceTree = "<group>"; }; D0C7D42224F76169001EBDBB /* IdentitiesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentitiesView.swift; sourceTree = "<group>"; };
@ -416,10 +420,11 @@
D0C7D42024F76169001EBDBB /* Views */ = { D0C7D42024F76169001EBDBB /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D01F41E024F8885900D55A2D /* Attachments */,
D0C7D42424F76169001EBDBB /* AddIdentityView.swift */, D0C7D42424F76169001EBDBB /* AddIdentityView.swift */,
D01F41E024F8885900D55A2D /* Attachments */,
D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */, D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */,
D0C7D42224F76169001EBDBB /* IdentitiesView.swift */, D0C7D42224F76169001EBDBB /* IdentitiesView.swift */,
D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */,
D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */, D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */,
D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */, D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */,
D0C7D42624F76169001EBDBB /* PreferencesView.swift */, D0C7D42624F76169001EBDBB /* PreferencesView.swift */,
@ -577,6 +582,7 @@
D0C7D48324F76169001EBDBB /* ContextEndpoint.swift */, D0C7D48324F76169001EBDBB /* ContextEndpoint.swift */,
D0C7D48224F76169001EBDBB /* DeletionEndpoint.swift */, D0C7D48224F76169001EBDBB /* DeletionEndpoint.swift */,
D0C7D47D24F76169001EBDBB /* InstanceEndpoint.swift */, D0C7D47D24F76169001EBDBB /* InstanceEndpoint.swift */,
D0BEB1F424F9A216001B0F04 /* Paged.swift */,
D0C7D47C24F76169001EBDBB /* PreferencesEndpoint.swift */, D0C7D47C24F76169001EBDBB /* PreferencesEndpoint.swift */,
D0C7D47B24F76169001EBDBB /* PushSubscriptionEndpoint.swift */, D0C7D47B24F76169001EBDBB /* PushSubscriptionEndpoint.swift */,
D0C7D48424F76169001EBDBB /* StatusEndpoint.swift */, D0C7D48424F76169001EBDBB /* StatusEndpoint.swift */,
@ -860,6 +866,7 @@
D0C7D4CD24F7616A001EBDBB /* AddIdentityViewModel.swift in Sources */, D0C7D4CD24F7616A001EBDBB /* AddIdentityViewModel.swift in Sources */,
D03658D124EDD80900AC17EC /* ContextEndpoint+Stubbing.swift in Sources */, D03658D124EDD80900AC17EC /* ContextEndpoint+Stubbing.swift in Sources */,
D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */, D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */,
D0BEB1F524F9A216001B0F04 /* Paged.swift in Sources */,
D0DC174A24CFF15F00A75C65 /* AppAuthorizationEndpoint+Stubbing.swift in Sources */, D0DC174A24CFF15F00A75C65 /* AppAuthorizationEndpoint+Stubbing.swift in Sources */,
D0C7D49A24F7616A001EBDBB /* StatusListView.swift in Sources */, D0C7D49A24F7616A001EBDBB /* StatusListView.swift in Sources */,
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */, D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */,
@ -908,6 +915,7 @@
D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */, D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */,
D0C7D4CF24F7616A001EBDBB /* StatusViewModel.swift in Sources */, D0C7D4CF24F7616A001EBDBB /* StatusViewModel.swift in Sources */,
D0C7D4C724F7616A001EBDBB /* PostingReadingPreferencesViewModel.swift in Sources */, D0C7D4C724F7616A001EBDBB /* PostingReadingPreferencesViewModel.swift in Sources */,
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */,
D0DC175224D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */, D0DC175224D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */,
D0C7D4F124F7616A001EBDBB /* IdentityService.swift in Sources */, D0C7D4F124F7616A001EBDBB /* IdentityService.swift in Sources */,
D04FD74224D4AA34007D572D /* DevelopmentModels.swift in Sources */, D04FD74224D4AA34007D572D /* DevelopmentModels.swift in Sources */,

View File

@ -0,0 +1,46 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
struct Paged<T: MastodonEndpoint> {
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 }
}

View File

@ -5,6 +5,7 @@ import Combine
struct ContextService { struct ContextService {
let statusSections: AnyPublisher<[[Status]], Error> let statusSections: AnyPublisher<[[Status]], Error>
let paginates = false
private let status: Status private let status: Status
private let context = CurrentValueSubject<MastodonContext, Never>(MastodonContext(ancestors: [], descendants: [])) private let context = CurrentValueSubject<MastodonContext, Never>(MastodonContext(ancestors: [], descendants: []))

View File

@ -5,6 +5,7 @@ import Combine
protocol StatusListService { protocol StatusListService {
var statusSections: AnyPublisher<[[Status]], Error> { get } var statusSections: AnyPublisher<[[Status]], Error> { get }
var paginates: Bool { get }
var contextParentID: String? { get } var contextParentID: String? { get }
func isPinned(status: Status) -> Bool func isPinned(status: Status) -> Bool
func isReplyInContext(status: Status) -> Bool func isReplyInContext(status: Status) -> Bool
@ -15,6 +16,8 @@ protocol StatusListService {
} }
extension StatusListService { extension StatusListService {
var paginates: Bool { true }
var contextParentID: String? { nil } var contextParentID: String? { nil }
func isPinned(status: Status) -> Bool { false } func isPinned(status: Status) -> Bool { false }

View File

@ -22,7 +22,7 @@ struct TimelineService {
extension TimelineService: StatusListService { extension TimelineService: StatusListService {
func request(maxID: String?, minID: String?) -> AnyPublisher<Never, Error> { func request(maxID: String?, minID: String?) -> AnyPublisher<Never, Error> {
return networkClient.request(timeline.endpoint) networkClient.request(Paged(timeline.endpoint, maxID: maxID, minID: minID))
.map { ($0, timeline) } .map { ($0, timeline) }
.flatMap(contentDatabase.insert(statuses:collection:)) .flatMap(contentDatabase.insert(statuses:collection:))
.eraseToAnyPublisher() .eraseToAnyPublisher()

View File

@ -5,6 +5,7 @@ import Combine
class StatusListViewController: UITableViewController { class StatusListViewController: UITableViewController {
private let viewModel: StatusListViewModel private let viewModel: StatusListViewModel
private let loadingTableFooterView = LoadingTableFooterView()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var cellHeightCaches = [CGFloat: [String: CGFloat]]() private var cellHeightCaches = [CGFloat: [String: CGFloat]]()
@ -30,6 +31,7 @@ class StatusListViewController: UITableViewController {
super.init(style: .plain) super.init(style: .plain)
} }
@available(*, unavailable)
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
@ -45,8 +47,10 @@ class StatusListViewController: UITableViewController {
} }
tableView.dataSource = dataSource tableView.dataSource = dataSource
tableView.prefetchDataSource = self
tableView.cellLayoutMarginsFollowReadableWidth = true tableView.cellLayoutMarginsFollowReadableWidth = true
tableView.separatorInset = .zero tableView.separatorInset = .zero
tableView.tableFooterView = UIView()
viewModel.$statusIDs viewModel.$statusIDs
.sink { [weak self] in .sink { [weak self] in
@ -73,6 +77,16 @@ class StatusListViewController: UITableViewController {
} }
} }
.store(in: &cancellables) .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) { override func viewWillAppear(_ animated: Bool) {
@ -114,6 +128,26 @@ class StatusListViewController: UITableViewController {
StatusListViewController(viewModel: contextViewModel), StatusListViewController(viewModel: contextViewModel),
animated: true) 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 { extension StatusListViewController: StatusTableViewCellDelegate {
@ -130,6 +164,35 @@ private extension StatusListViewController {
present(activityViewController, animated: true, completion: nil) 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 { private extension Array where Element: Sequence, Element.Element: Hashable {

View File

@ -29,6 +29,8 @@ class StatusListViewModel: ObservableObject {
} }
extension StatusListViewModel { extension StatusListViewModel {
var paginates: Bool { statusListService.paginates }
var contextParentID: String? { statusListService.contextParentID } var contextParentID: String? { statusListService.contextParentID }
func request(maxID: String? = nil, minID: String? = nil) { func request(maxID: String? = nil, minID: String? = nil) {

View File

@ -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")
}
}