diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index fb5c211e2..58680f7f1 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; }; 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */; }; 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; }; + 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */; }; 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; }; 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46976325C2A71500CF4AA9 /* UIIamge.swift */; }; 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; }; @@ -153,6 +154,7 @@ 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionToolBarContainer.swift; sourceTree = ""; }; 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTestExpandedButton.swift; sourceTree = ""; }; 2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = ""; }; + 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = ""; }; 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = ""; }; 2D46976325C2A71500CF4AA9 /* UIIamge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIIamge.swift; sourceTree = ""; }; 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = ""; }; @@ -357,6 +359,7 @@ 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */, 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */, 2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */, + 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */, ); path = PublicTimeline; sourceTree = ""; @@ -1047,6 +1050,7 @@ 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, + 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index ce3bcd298..871b72b79 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -5,22 +5,22 @@ // Created by sxiaojian on 2021/1/27. // -import os.log -import UIKit import AVKit import Combine import CoreDataStack import GameplayKit +import os.log +import UIKit final class PublicTimelineViewController: UIViewController, NeedsDependency, TimelinePostTableViewCellDelegate { - weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() var viewModel: PublicTimelineViewModel! - + let refreshControl = UIRefreshControl() + lazy var tableView: UITableView = { let tableView = UITableView() tableView.register(TimelinePostTableViewCell.self, forCellReuseIdentifier: String(describing: TimelinePostTableViewCell.self)) @@ -30,16 +30,30 @@ final class PublicTimelineViewController: UIViewController, NeedsDependency, Tim }() deinit { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) } - } extension PublicTimelineViewController { - override func viewDidLoad() { super.viewDidLoad() + tableView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(PublicTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged) + // bind refresh control + viewModel.isFetchingLatestTimeline + .receive(on: DispatchQueue.main) + .sink { [weak self] isFetching in + guard let self = self else { return } + if !isFetching { + UIView.animate(withDuration: 0.5) { [weak self] in + guard let self = self else { return } + self.refreshControl.endRefreshing() + } + } + } + .store(in: &disposeBag) + tableView.translatesAutoresizingMaskIntoConstraints = false tableView.backgroundColor = Asset.Colors.tootDark.color view.addSubview(tableView) @@ -60,6 +74,7 @@ extension PublicTimelineViewController { timelinePostTableViewCellDelegate: self ) } + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) viewModel.fetchLatest() @@ -67,7 +82,7 @@ extension PublicTimelineViewController { .sink { completion in switch completion { case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: fetch user timeline latest response error: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log("%{public}s[%{public}ld], %{public}s: fetch user timeline latest response error: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) case .finished: break } @@ -77,12 +92,22 @@ extension PublicTimelineViewController { } .store(in: &viewModel.disposeBag) } - +} + +// MARK: - Selector + +extension PublicTimelineViewController { + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { + guard viewModel.stateMachine.enter(PublicTimelineViewModel.State.Loading.self) else { + sender.endRefreshing() + return + } + } } // MARK: - UITableViewDelegate + extension PublicTimelineViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } @@ -94,12 +119,9 @@ extension PublicTimelineViewController: UITableViewDelegate { return ceil(frame.height) } - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - - } + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {} func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index c2ee8e5db..ca323ee47 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -5,10 +5,10 @@ // Created by sxiaojian on 2021/1/27. // -import os.log -import UIKit import CoreData import CoreDataStack +import os.log +import UIKit extension PublicTimelineViewModel { func setupDiffableDataSource( @@ -20,26 +20,27 @@ extension PublicTimelineViewModel { .autoconnect() .share() .eraseToAnyPublisher() - + diffableDataSource = TimelineSection.tableViewDiffableDataSource( for: tableView, dependency: dependency, managedObjectContext: fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, - timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate) + timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate + ) items.value = [] } } // MARK: - NSFetchedResultsControllerDelegate + extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - + os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) + let indexes = tootIDs.value let toots = fetchedResultsController.fetchedObjects ?? [] - guard toots.count == indexes.count else { return } - + let items: [Item] = toots .compactMap { toot -> (Int, Toot)? in guard toot.deletedAt == nil else { return nil } @@ -49,5 +50,4 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { .map { Item.toot(objectID: $0.1.objectID) } self.items.value = items } - } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift new file mode 100644 index 000000000..ce55ae886 --- /dev/null +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift @@ -0,0 +1,132 @@ +// +// PublicTimelineViewModel+State.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/2. +// + +import Foundation +import GameplayKit +import MastodonSDK +import os.log + +extension PublicTimelineViewModel { + class State: GKState { + weak var viewModel: PublicTimelineViewModel? + + init(viewModel: PublicTimelineViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, self.debugDescription, previousState.debugDescription) + } + } +} + +extension PublicTimelineViewModel.State { + class Initial: PublicTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + } + + class Loading: PublicTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: + return true + case is Idle.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 } + + viewModel.fetchLatest() + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: fetch user timeline latest response error: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) + + case .finished: + break + } + } receiveValue: { response in + viewModel.isFetchingLatestTimeline.value = false + let tootsIDs = response.value.map { $0.id } + viewModel.tootIDs.value = tootsIDs + stateMachine.enter(Idle.self) + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: PublicTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type, is LoadingMore.Type: + return true + default: + return false + } + } + } + + class Idle: PublicTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type, is LoadingMore.Type: + return true + default: + return false + } + } + } + + class LoadingMore: PublicTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: + return true + case is Idle.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 } + + viewModel.loadMore() + .sink { completion in + switch completion { + case .failure(let error): + stateMachine.enter(Fail.self) + os_log("%{public}s[%{public}ld], %{public}s: load more fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + break + } + } receiveValue: { response in + viewModel.isFetchingLatestTimeline.value = false + let tootsIDs = response.value.map { $0.id } + viewModel.tootIDs.value = tootsIDs + stateMachine.enter(Idle.self) + } + .store(in: &viewModel.disposeBag) + } + } +} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift index 2150bb25b..4b1a05a77 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift @@ -5,28 +5,39 @@ // Created by sxiaojian on 2021/1/27. // -import os.log -import UIKit -import GameplayKit +import AlamofireImage import Combine import CoreData import CoreDataStack +import GameplayKit import MastodonSDK -import AlamofireImage - +import os.log +import UIKit class PublicTimelineViewModel: NSObject { - var disposeBag = Set() // input let context: AppContext let fetchedResultsController: NSFetchedResultsController + let isFetchingLatestTimeline = CurrentValueSubject(false) weak var tableView: UITableView? // output var diffableDataSource: UITableViewDiffableDataSource? + lazy var stateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + State.Initial(viewModel: self), + State.Loading(viewModel: self), + State.Fail(viewModel: self), + State.Idle(viewModel: self), + State.LoadingMore(viewModel: self), + ]) + stateMachine.enter(State.Initial.self) + return stateMachine + }() + let tootIDs = CurrentValueSubject<[String], Never>([]) let items = CurrentValueSubject<[Item], Never>([]) var cellFrameCache = NSCache() @@ -49,7 +60,7 @@ class PublicTimelineViewModel: NSObject { }() super.init() - self.fetchedResultsController.delegate = self + fetchedResultsController.delegate = self items .receive(on: DispatchQueue.main) @@ -57,7 +68,7 @@ class PublicTimelineViewModel: NSObject { .sink { [weak self] items in guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } - os_log("%{public}s[%{public}ld], %{public}s: items did change", ((#file as NSString).lastPathComponent), #line, #function) + os_log("%{public}s[%{public}ld], %{public}s: items did change", (#file as NSString).lastPathComponent, #line, #function) var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) @@ -82,14 +93,16 @@ class PublicTimelineViewModel: NSObject { } deinit { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) } - } extension PublicTimelineViewModel { - func fetchLatest() -> AnyPublisher, Error> { return context.apiService.publicTimeline(count: 20, domain: "mstdn.jp") } + + func loadMore() -> AnyPublisher, Error> { + return context.apiService.publicTimeline(count: 20, domain: "mstdn.jp") + } }