feat: supports hashtag timeline pull-down-refresh

This commit is contained in:
CMK 2022-05-16 11:34:20 +08:00
parent 7a3145083a
commit 39ff50212b
6 changed files with 161 additions and 58 deletions

View File

@ -12,7 +12,7 @@
0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; }; 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; };
0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; }; 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; };
0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */; }; 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */; };
0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */; }; 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+State.swift */; };
0F20223926146553000C64BF /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array.swift */; }; 0F20223926146553000C64BF /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array.swift */; };
0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */; }; 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */; };
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; }; 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; };
@ -715,7 +715,7 @@
0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = "<group>"; }; 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = "<group>"; };
0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = "<group>"; }; 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = "<group>"; };
0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HashtagTimeline.swift"; sourceTree = "<group>"; }; 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HashtagTimeline.swift"; sourceTree = "<group>"; };
0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadOldestState.swift"; sourceTree = "<group>"; }; 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+State.swift"; sourceTree = "<group>"; };
0F20223826146553000C64BF /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = "<group>"; }; 0F20223826146553000C64BF /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = "<group>"; };
0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = "<group>"; }; 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = "<group>"; };
0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = "<group>"; }; 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = "<group>"; };
@ -1489,7 +1489,7 @@
DB0FCB9B27980AB6006C02E2 /* HashtagTimelineViewController+DataSourceProvider.swift */, DB0FCB9B27980AB6006C02E2 /* HashtagTimelineViewController+DataSourceProvider.swift */,
0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */, 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */,
0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */, 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */,
0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */, 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+State.swift */,
); );
path = HashtagTimeline; path = HashtagTimeline;
sourceTree = "<group>"; sourceTree = "<group>";
@ -4109,7 +4109,7 @@
DB6B74F4272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift in Sources */, DB6B74F4272FBAE700C70B6E /* FollowerListViewModel+Diffable.swift in Sources */,
DB6B74F2272FB67600C70B6E /* FollowerListViewModel.swift in Sources */, DB6B74F2272FB67600C70B6E /* FollowerListViewModel.swift in Sources */,
DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */, DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */,
0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */, 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+State.swift in Sources */,
DB0009A626AEE5DC009B9D2D /* Intents.intentdefinition in Sources */, DB0009A626AEE5DC009B9D2D /* Intents.intentdefinition in Sources */,
5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */, 5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */,
DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */, DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */,

View File

@ -114,7 +114,7 @@
<key>MastodonIntent.xcscheme_^#shared#^_</key> <key>MastodonIntent.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>22</integer> <integer>20</integer>
</dict> </dict>
<key>MastodonIntents.xcscheme_^#shared#^_</key> <key>MastodonIntents.xcscheme_^#shared#^_</key>
<dict> <dict>
@ -129,12 +129,12 @@
<key>NotificationService.xcscheme_^#shared#^_</key> <key>NotificationService.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>20</integer> <integer>21</integer>
</dict> </dict>
<key>ShareActionExtension.xcscheme_^#shared#^_</key> <key>ShareActionExtension.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>21</integer> <integer>22</integer>
</dict> </dict>
</dict> </dict>
<key>SuppressBuildableAutocreation</key> <key>SuppressBuildableAutocreation</key>

View File

@ -46,6 +46,8 @@ final class HashtagTimelineViewController: UIViewController, NeedsDependency, Me
return tableView return tableView
}() }()
let refreshControl = UIRefreshControl()
deinit { deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
} }
@ -89,13 +91,23 @@ extension HashtagTimelineViewController {
statusTableViewCellDelegate: self statusTableViewCellDelegate: self
) )
tableView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(HashtagTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged)
viewModel.didLoadLatest
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.refreshControl.endRefreshing()
}
.store(in: &disposeBag)
// setup batch fetch // setup batch fetch
viewModel.listBatchFetchViewModel.setup(scrollView: tableView) viewModel.listBatchFetchViewModel.setup(scrollView: tableView)
viewModel.listBatchFetchViewModel.shouldFetch viewModel.listBatchFetchViewModel.shouldFetch
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
guard let self = self else { return } guard let self = self else { return }
self.viewModel.loadOldestStateMachine.enter(HashtagTimelineViewModel.LoadOldestState.Loading.self) self.viewModel.stateMachine.enter(HashtagTimelineViewModel.State.Loading.self)
} }
.store(in: &disposeBag) .store(in: &disposeBag)
@ -144,6 +156,13 @@ extension HashtagTimelineViewController {
extension HashtagTimelineViewController { extension HashtagTimelineViewController {
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
guard viewModel.stateMachine.enter(HashtagTimelineViewModel.State.Reloading.self) else {
sender.endRefreshing()
return
}
}
@objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }

View File

@ -27,6 +27,8 @@ extension HashtagTimelineViewModel {
) )
) )
stateMachine.enter(State.Reloading.self)
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>() var snapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>()
snapshot.appendSections([.main]) snapshot.appendSections([.main])
diffableDataSource?.apply(snapshot) diffableDataSource?.apply(snapshot)
@ -43,14 +45,17 @@ extension HashtagTimelineViewModel {
let items = records.map { StatusItem.status(record: $0) } let items = records.map { StatusItem.status(record: $0) }
snapshot.appendItems(items, toSection: .main) snapshot.appendItems(items, toSection: .main)
if let currentState = self.loadOldestStateMachine.currentState { if let currentState = self.stateMachine.currentState {
switch currentState { switch currentState {
case is LoadOldestState.Initial, case is State.Initial,
is LoadOldestState.Loading, is State.Reloading,
is LoadOldestState.Idle, is State.Loading,
is LoadOldestState.Fail: is State.Idle,
snapshot.appendItems([.bottomLoader], toSection: .main) is State.Fail:
case is LoadOldestState.NoMore: if !items.isEmpty {
snapshot.appendItems([.bottomLoader], toSection: .main)
}
case is State.NoMore:
break break
default: default:
assertionFailure() assertionFailure()

View File

@ -1,5 +1,5 @@
// //
// HashtagTimelineViewModel+LoadOldestState.swift // HashtagTimelineViewModel+State.swift
// Mastodon // Mastodon
// //
// Created by BradGao on 2021/3/31. // Created by BradGao on 2021/3/31.
@ -11,7 +11,7 @@ import GameplayKit
import CoreDataStack import CoreDataStack
extension HashtagTimelineViewModel { extension HashtagTimelineViewModel {
class LoadOldestState: GKState, NamingState { class State: GKState, NamingState {
let logger = Logger(subsystem: "HashtagTimelineViewModel.LoadOldestState", category: "StateMachine") let logger = Logger(subsystem: "HashtagTimelineViewModel.LoadOldestState", category: "StateMachine")
@ -28,14 +28,14 @@ extension HashtagTimelineViewModel {
} }
override func didEnter(from previousState: GKState?) { override func didEnter(from previousState: GKState?) {
let previousState = previousState as? HashtagTimelineViewModel.LoadOldestState let previousState = previousState as? HashtagTimelineViewModel.State
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")") logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")")
viewModel?.loadOldestStateMachinePublisher.send(self) viewModel?.loadOldestStateMachinePublisher.send(self)
} }
@MainActor @MainActor
func enter(state: LoadOldestState.Type) { func enter(state: State.Type) {
stateMachine?.enter(state) stateMachine?.enter(state)
} }
@ -45,24 +45,96 @@ extension HashtagTimelineViewModel {
} }
} }
extension HashtagTimelineViewModel.LoadOldestState { extension HashtagTimelineViewModel.State {
class Initial: HashtagTimelineViewModel.LoadOldestState { class Initial: HashtagTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool { override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Loading.self switch stateClass {
case is Reloading.Type:
return true
default:
return false
}
} }
} }
class Loading: HashtagTimelineViewModel.LoadOldestState { class Reloading: HashtagTimelineViewModel.State {
var maxID: Status.ID?
override func isValidNextState(_ stateClass: AnyClass) -> Bool { override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self switch stateClass {
case is Loading.Type:
return true
default:
return false
}
} }
override func didEnter(from previousState: GKState?) { override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState) super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return } guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
stateMachine.enter(Loading.self)
}
}
class Fail: HashtagTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Loading.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let _ = viewModel, let stateMachine = stateMachine else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function)
stateMachine.enter(Loading.self)
}
}
}
class Idle: HashtagTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Reloading.Type, is Loading.Type:
return true
default:
return false
}
}
}
class Loading: HashtagTimelineViewModel.State {
var maxID: Status.ID?
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
switch stateClass {
case is Fail.Type:
return true
case is Idle.Type:
return true
case is NoMore.Type:
return true
default:
return false
}
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
switch previousState {
case is Reloading:
maxID = nil
default:
break
}
guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
assertionFailure() assertionFailure()
stateMachine.enter(Fail.self) stateMachine.enter(Fail.self)
@ -71,6 +143,8 @@ extension HashtagTimelineViewModel.LoadOldestState {
// TODO: only set large count when using Wi-Fi // TODO: only set large count when using Wi-Fi
let maxID = self.maxID let maxID = self.maxID
let isReloading = maxID == nil
Task { Task {
do { do {
let response = try await viewModel.context.apiService.hashtagTimeline( let response = try await viewModel.context.apiService.hashtagTimeline(
@ -79,24 +153,35 @@ extension HashtagTimelineViewModel.LoadOldestState {
hashtag: viewModel.hashtag, hashtag: viewModel.hashtag,
authenticationBox: authenticationBox authenticationBox: authenticationBox
) )
let newMaxID: String? = {
guard let maxID = response.link?.maxID else { return nil }
return maxID
}()
var hasMore = false let hasMore: Bool = {
guard let newMaxID = newMaxID else { return false }
return newMaxID != maxID
}()
if let _maxID = response.link?.maxID, self.maxID = newMaxID
_maxID != maxID
{ var hasNewStatusesAppend = false
self.maxID = _maxID var statusIDs = isReloading ? [] : viewModel.fetchedResultsController.statusIDs.value
hasMore = true for status in response.value {
guard !statusIDs.contains(status.id) else { continue }
statusIDs.append(status.id)
hasNewStatusesAppend = true
} }
if hasMore {
if hasNewStatusesAppend, hasMore {
await enter(state: Idle.self) await enter(state: Idle.self)
} else { } else {
await enter(state: NoMore.self) await enter(state: NoMore.self)
} }
let statusIDs = response.value.map { $0.id }
viewModel.fetchedResultsController.append(statusIDs: statusIDs) viewModel.fetchedResultsController.append(statusIDs: statusIDs)
viewModel.didLoadLatest.send()
} catch { } catch {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch statues failed: \(error.localizedDescription)") logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch statues failed: \(error.localizedDescription)")
await enter(state: Fail.self) await enter(state: Fail.self)
@ -104,22 +189,15 @@ extension HashtagTimelineViewModel.LoadOldestState {
} // end Task } // end Task
} }
} }
class Fail: HashtagTimelineViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Loading.self || stateClass == Idle.self
}
}
class Idle: HashtagTimelineViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Loading.self
}
}
class NoMore: HashtagTimelineViewModel.LoadOldestState { class NoMore: HashtagTimelineViewModel.State {
override func isValidNextState(_ stateClass: AnyClass) -> Bool { override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return false switch stateClass {
case is Reloading.Type:
return true
default:
return false
}
} }
override func didEnter(from previousState: GKState?) { override func didEnter(from previousState: GKState?) {

View File

@ -36,19 +36,20 @@ final class HashtagTimelineViewModel {
let didLoadLatest = PassthroughSubject<Void, Never>() let didLoadLatest = PassthroughSubject<Void, Never>()
// bottom loader // bottom loader
private(set) lazy var loadOldestStateMachine: GKStateMachine = { private(set) lazy var stateMachine: GKStateMachine = {
// exclude timeline middle fetcher state // exclude timeline middle fetcher state
let stateMachine = GKStateMachine(states: [ let stateMachine = GKStateMachine(states: [
LoadOldestState.Initial(viewModel: self), State.Initial(viewModel: self),
LoadOldestState.Loading(viewModel: self), State.Reloading(viewModel: self),
LoadOldestState.Fail(viewModel: self), State.Fail(viewModel: self),
LoadOldestState.Idle(viewModel: self), State.Idle(viewModel: self),
LoadOldestState.NoMore(viewModel: self), State.Loading(viewModel: self),
State.NoMore(viewModel: self),
]) ])
stateMachine.enter(LoadOldestState.Initial.self) stateMachine.enter(State.Initial.self)
return stateMachine return stateMachine
}() }()
lazy var loadOldestStateMachinePublisher = CurrentValueSubject<LoadOldestState?, Never>(nil) lazy var loadOldestStateMachinePublisher = CurrentValueSubject<State?, Never>(nil)
init(context: AppContext, hashtag: String) { init(context: AppContext, hashtag: String) {
self.context = context self.context = context