Load more WIP

This commit is contained in:
Justin Mazzocchi 2020-10-04 01:39:54 -07:00
parent 9507343511
commit 7f937601b1
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
18 changed files with 255 additions and 23 deletions

View file

@ -74,6 +74,7 @@ extension ContentDatabase {
try db.create(table: "loadMoreRecord") { t in
t.column("timelineId").notNull().references("timelineRecord", onDelete: .cascade)
t.column("afterStatusId", .text).notNull()
t.column("beforeStatusId", .text).notNull()
t.primaryKey(["timelineId", "afterStatusId"], onConflict: .replace)
}

View file

@ -49,7 +49,10 @@ public extension ContentDatabase {
.eraseToAnyPublisher()
}
func insert(statuses: [Status], timeline: Timeline) -> AnyPublisher<Never, Error> {
func insert(
statuses: [Status],
timeline: Timeline,
loadMoreAndDirection: (LoadMore, LoadMore.Direction)? = nil) -> AnyPublisher<Never, Error> {
databaseWriter.writePublisher {
let timelineRecord = TimelineRecord(timeline: timeline)
@ -66,7 +69,38 @@ public extension ContentDatabase {
if let maxIDPresent = maxIDPresent,
let minIDInserted = statuses.map(\.id).min(),
minIDInserted > maxIDPresent {
try LoadMoreRecord(timelineId: timeline.id, afterStatusId: minIDInserted).save($0)
try LoadMoreRecord(
timelineId: timeline.id,
afterStatusId: minIDInserted,
beforeStatusId: maxIDPresent)
.save($0)
}
guard let (loadMore, direction) = loadMoreAndDirection else { return }
try LoadMoreRecord(
timelineId: loadMore.timeline.id,
afterStatusId: loadMore.afterStatusId,
beforeStatusId: loadMore.beforeStatusId)
.delete($0)
switch direction {
case .up:
if let maxIDInserted = statuses.map(\.id).max(), maxIDInserted < loadMore.afterStatusId {
try LoadMoreRecord(
timelineId: loadMore.timeline.id,
afterStatusId: loadMore.afterStatusId,
beforeStatusId: maxIDInserted)
.save($0)
}
case .down:
if let minIDInserted = statuses.map(\.id).min(), minIDInserted > loadMore.beforeStatusId {
try LoadMoreRecord(
timelineId: loadMore.timeline.id,
afterStatusId: minIDInserted,
beforeStatusId: loadMore.beforeStatusId)
.save($0)
}
}
}
.ignoreOutput()

View file

@ -7,12 +7,14 @@ import Mastodon
struct LoadMoreRecord: Codable, Hashable {
let timelineId: String
let afterStatusId: String
let beforeStatusId: String
}
extension LoadMoreRecord {
enum Columns {
static let timelineId = Column(LoadMoreRecord.CodingKeys.timelineId)
static let afterStatusId = Column(LoadMoreRecord.CodingKeys.afterStatusId)
static let beforeStatusId = Column(LoadMoreRecord.CodingKeys.beforeStatusId)
}
}

View file

@ -42,7 +42,10 @@ extension TimelineItemsInfo {
}) else { continue }
timelineItems.insert(
.loadMore(LoadMore(timeline: timeline, afterStatusId: loadMoreRecord.afterStatusId)),
.loadMore(LoadMore(
timeline: timeline,
afterStatusId: loadMoreRecord.afterStatusId,
beforeStatusId: loadMoreRecord.beforeStatusId)),
at: index)
}

View file

@ -38,7 +38,7 @@ extension TimelineRecord {
StatusRecord.self,
through: statusJoins,
using: TimelineStatusJoin.status)
.order(StatusRecord.Columns.createdAt.desc)
.order(StatusRecord.Columns.id.desc)
static let account = belongsTo(AccountRecord.self, using: ForeignKey([Columns.accountId]))
static let loadMores = hasMany(LoadMoreRecord.self)

View file

@ -1,12 +1,12 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import GRDB
import Mastodon
public struct LoadMore: Hashable {
public let timeline: Timeline
public let afterStatusId: String
public let beforeStatusId: String
}
public extension LoadMore {

View file

@ -54,6 +54,8 @@
D0E2C1D124FD97F000854680 /* ViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D0E2C1D024FD97F000854680 /* ViewModels */; };
D0E5361C24E3EB4D00FB1CE1 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */; };
D0E5362024E3EB4D00FB1CE1 /* Notification Service Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0E5361924E3EB4D00FB1CE1 /* Notification Service Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D0E569DB2529319100FA1D72 /* LoadMoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E569DA2529319100FA1D72 /* LoadMoreView.swift */; };
D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.swift */; };
D0EA59402522AC8700804347 /* CardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EA593F2522AC8700804347 /* CardView.swift */; };
D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EA59472522B8B600804347 /* ViewConstants.swift */; };
D0F0B10E251A868200942152 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B10D251A868200942152 /* AccountView.swift */; };
@ -154,6 +156,8 @@
D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
D0E5361D24E3EB4D00FB1CE1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D0E5362824E4A06B00FB1CE1 /* Notification Service Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Notification Service Extension.entitlements"; sourceTree = "<group>"; };
D0E569DA2529319100FA1D72 /* LoadMoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreView.swift; sourceTree = "<group>"; };
D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreContentConfiguration.swift; sourceTree = "<group>"; };
D0EA593F2522AC8700804347 /* CardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardView.swift; sourceTree = "<group>"; };
D0EA59472522B8B600804347 /* ViewConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewConstants.swift; sourceTree = "<group>"; };
D0F0B10D251A868200942152 /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = "<group>"; };
@ -291,6 +295,8 @@
D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */,
D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */,
D0B8510B25259E56004E0744 /* LoadMoreCell.swift */,
D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.swift */,
D0E569DA2529319100FA1D72 /* LoadMoreView.swift */,
D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */,
D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */,
D0C7D42624F76169001EBDBB /* PreferencesView.swift */,
@ -534,6 +540,7 @@
D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */,
D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */,
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */,
D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */,
D0F0B136251AA12700942152 /* CollectionItemKind+Extensions.swift in Sources */,
D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */,
D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */,
@ -544,6 +551,7 @@
D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */,
D0625E5F250F0CFF00502611 /* StatusView.swift in Sources */,
D0625E59250F092900502611 /* StatusListCell.swift in Sources */,
D0E569DB2529319100FA1D72 /* LoadMoreView.swift in Sources */,
D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */,
D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */,
D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */,

View file

@ -21,8 +21,13 @@ public extension LoadMoreService {
mastodonAPIClient.pagedRequest(
loadMore.timeline.endpoint,
maxID: direction == .down ? loadMore.afterStatusId : nil,
minID: direction == .up ? loadMore.afterStatusId : nil)
.flatMap { contentDatabase.insert(statuses: $0.result, timeline: loadMore.timeline) }
minID: direction == .up ? loadMore.beforeStatusId : nil)
.flatMap {
contentDatabase.insert(
statuses: $0.result,
timeline: loadMore.timeline,
loadMoreAndDirection: (loadMore, direction))
}
.eraseToAnyPublisher()
}
}

View file

@ -74,6 +74,10 @@ public extension NavigationService {
func accountService(account: Account) -> AccountService {
AccountService(account: account, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
}
func loadMoreService(loadMore: LoadMore) -> LoadMoreService {
LoadMoreService(loadMore: loadMore, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase)
}
}
private extension NavigationService {

View file

@ -28,11 +28,7 @@ class TableViewController: UITableViewController {
case (let accountListCell as AccountListCell, let accountViewModel as AccountViewModel):
accountListCell.viewModel = accountViewModel
case (let loadMoreCell as LoadMoreCell, let loadMoreViewModel as LoadMoreViewModel):
var contentConfiguration = loadMoreCell.defaultContentConfiguration()
contentConfiguration.text = NSLocalizedString("load-more", comment: "")
loadMoreCell.contentConfiguration = contentConfiguration
loadMoreCell.viewModel = loadMoreViewModel
default:
return nil
}

View file

@ -1,7 +1,31 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import ServiceLayer
public class LoadMoreViewModel: ObservableObject {
@Published var loading = false
public struct LoadMoreViewModel {
public let loading: AnyPublisher<Bool, Never>
public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never>
private let loadMoreService: LoadMoreService
private let eventsSubject = PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, Never>()
private let loadingSubject = PassthroughSubject<Bool, Never>()
init(loadMoreService: LoadMoreService) {
self.loadMoreService = loadMoreService
loading = loadingSubject.eraseToAnyPublisher()
events = eventsSubject.eraseToAnyPublisher()
}
}
extension LoadMoreViewModel {
func loadMore() {
eventsSubject.send(
loadMoreService.request(direction: .down)
.handleEvents(
receiveSubscription: { _ in loadingSubject.send(true) },
receiveCompletion: { _ in loadingSubject.send(false) })
.map { _ in CollectionItemEvent.ignorableOutput }
.eraseToAnyPublisher())
}
}

View file

@ -67,8 +67,8 @@ extension StatusListViewModel: CollectionViewModel {
statusListService: statusListService
.navigationService
.contextStatusListService(id: configuration.status.displayStatus.id))))
default:
break
case .loadMore:
loadMoreViewModel(item: item)?.loadMore()
}
}
@ -85,7 +85,7 @@ extension StatusListViewModel: CollectionViewModel {
case .status:
return statusViewModel(item: item)
case .loadMore:
return LoadMoreViewModel()
return loadMoreViewModel(item: item)
default:
return nil
}
@ -127,6 +127,33 @@ private extension StatusListViewModel {
return statusViewModel
}
func loadMoreViewModel(item: CollectionItemIdentifier) -> LoadMoreViewModel? {
guard let timelineItem = timelineItems[item],
case let .loadMore(loadMore) = timelineItem
else { return nil }
if let cachedViewModel = viewModelCache[timelineItem]?.0 as? LoadMoreViewModel {
return cachedViewModel
}
let loadMoreViewModel = LoadMoreViewModel(
loadMoreService: statusListService.navigationService.loadMoreService(loadMore: loadMore))
viewModelCache[timelineItem] = (loadMoreViewModel, loadMoreViewModel.events
.flatMap { $0 }
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink { [weak self] in
guard
let self = self,
let event = NavigationEvent($0)
else { return }
self.navigationEventsSubject.send(event)
})
return loadMoreViewModel
}
func process(sections: [[Timeline.Item]]) {
determineIfScrollPositionShouldBeMaintained(newSections: sections)

View file

@ -15,9 +15,12 @@ class AccountListCell: UITableViewCell {
override func layoutSubviews() {
super.layoutSubviews()
let isPhoneIdiom = UIDevice.current.userInterfaceIdiom == .phone
separatorInset.right = isPhoneIdiom ? 0 : layoutMargins.right
separatorInset.left = isPhoneIdiom ? 0 : layoutMargins.left
if UIDevice.current.userInterfaceIdiom == .phone {
separatorInset.left = 0
separatorInset.right = 0
} else {
separatorInset.left = layoutMargins.left
separatorInset.right = layoutMargins.right
}
}
}

View file

@ -1,11 +1,26 @@
// Copyright © 2020 Metabolist. All rights reserved.
import UIKit
import ViewModels
class LoadMoreCell: UITableViewCell {
var viewModel: LoadMoreViewModel?
override func updateConfiguration(using state: UICellConfigurationState) {
guard let viewModel = viewModel else { return }
contentConfiguration = LoadMoreContentConfiguration(viewModel: viewModel)
}
override func layoutSubviews() {
super.layoutSubviews()
separatorInset.left = UIDevice.current.userInterfaceIdiom == .phone ? 0 : layoutMargins.left
if UIDevice.current.userInterfaceIdiom == .phone {
separatorInset.left = 0
separatorInset.right = 0
} else {
separatorInset.left = layoutMargins.left
separatorInset.right = layoutMargins.right
}
}
}

View file

@ -0,0 +1,18 @@
// Copyright © 2020 Metabolist. All rights reserved.
import UIKit
import ViewModels
struct LoadMoreContentConfiguration {
let viewModel: LoadMoreViewModel
}
extension LoadMoreContentConfiguration: UIContentConfiguration {
func makeContentView() -> UIView & UIContentView {
LoadMoreView(configuration: self)
}
func updated(for state: UIConfigurationState) -> LoadMoreContentConfiguration {
self
}
}

91
Views/LoadMoreView.swift Normal file
View file

@ -0,0 +1,91 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
import UIKit
class LoadMoreView: UIView {
private let label = UILabel()
private let activityIndicatorView = UIActivityIndicatorView()
private var loadMoreConfiguration: LoadMoreContentConfiguration
private var loadingCancellable: AnyCancellable?
init(configuration: LoadMoreContentConfiguration) {
self.loadMoreConfiguration = configuration
super.init(frame: .zero)
initialSetup()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension LoadMoreView: UIContentView {
var configuration: UIContentConfiguration {
get { loadMoreConfiguration }
set {
guard let loadMoreConfiguration = newValue as? LoadMoreContentConfiguration else { return }
self.loadMoreConfiguration = loadMoreConfiguration
applyLoadMoreConfiguration()
}
}
}
private extension LoadMoreView {
func initialSetup() {
let leadingArrowImageView = UIImageView()
let trailingArrowImageView = UIImageView()
for arrowImageView in [leadingArrowImageView, trailingArrowImageView] {
addSubview(arrowImageView)
arrowImageView.translatesAutoresizingMaskIntoConstraints = false
arrowImageView.image = UIImage(
systemName: "arrow.up.circle",
withConfiguration: UIImage.SymbolConfiguration(
pointSize: UIFont.preferredFont(forTextStyle: .title2).pointSize))
arrowImageView.setContentHuggingPriority(.required, for: .horizontal)
}
addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
label.textAlignment = .center
label.font = .preferredFont(forTextStyle: .title2)
label.adjustsFontForContentSizeCategory = true
label.textColor = label.tintColor
label.text = NSLocalizedString("load-more", comment: "")
label.setContentHuggingPriority(.defaultLow, for: .horizontal)
addSubview(activityIndicatorView)
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
activityIndicatorView.hidesWhenStopped = true
NSLayoutConstraint.activate([
leadingArrowImageView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor),
leadingArrowImageView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor),
leadingArrowImageView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor),
label.leadingAnchor.constraint(equalTo: leadingArrowImageView.trailingAnchor),
label.topAnchor.constraint(greaterThanOrEqualTo: readableContentGuide.topAnchor),
label.bottomAnchor.constraint(greaterThanOrEqualTo: readableContentGuide.bottomAnchor),
label.trailingAnchor.constraint(equalTo: trailingArrowImageView.leadingAnchor),
trailingArrowImageView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor),
trailingArrowImageView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor),
trailingArrowImageView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor),
activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor),
activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor)
])
}
func applyLoadMoreConfiguration() {
loadingCancellable = loadMoreConfiguration.viewModel.loading.sink { [weak self] in
guard let self = self else { return }
self.label.isHidden = $0
$0 ? self.activityIndicatorView.startAnimating() : self.activityIndicatorView.stopAnimating()
}
}
}

View file

@ -125,7 +125,7 @@ private extension StatusView {
])
for constraint in separatorConstraints {
constraint.constant = 1 / UIScreen.main.scale
constraint.constant = .hairline
}
avatarImageView.kf.indicatorType = .activity

View file

@ -6,6 +6,7 @@ extension CGFloat {
static let defaultSpacing: Self = 8
static let compactSpacing: Self = 4
static let defaultCornerRadius: Self = 8
static let hairline = 1 / UIScreen.main.scale
}
extension TimeInterval {