feat: add pull to refresh

This commit is contained in:
sunxiaojian 2021-02-02 19:04:24 +08:00
parent 3e1d2bcc16
commit 29439c9746
5 changed files with 205 additions and 34 deletions

View File

@ -16,6 +16,7 @@
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; }; 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; };
2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */; }; 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */; };
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.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 */; }; 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; };
2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46976325C2A71500CF4AA9 /* UIIamge.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 */; }; 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 = "<group>"; }; 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionToolBarContainer.swift; sourceTree = "<group>"; };
2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTestExpandedButton.swift; sourceTree = "<group>"; }; 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTestExpandedButton.swift; sourceTree = "<group>"; };
2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = "<group>"; }; 2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = "<group>"; };
2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = "<group>"; };
2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = "<group>"; }; 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = "<group>"; };
2D46976325C2A71500CF4AA9 /* UIIamge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIIamge.swift; sourceTree = "<group>"; }; 2D46976325C2A71500CF4AA9 /* UIIamge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIIamge.swift; sourceTree = "<group>"; };
2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = "<group>"; }; 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = "<group>"; };
@ -357,6 +359,7 @@
2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */, 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */,
2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */, 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */,
2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */, 2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */,
2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */,
); );
path = PublicTimeline; path = PublicTimeline;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1047,6 +1050,7 @@
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */,
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */,
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */,
2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */, 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */,
DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */,
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,

View File

@ -5,22 +5,22 @@
// Created by sxiaojian on 2021/1/27. // Created by sxiaojian on 2021/1/27.
// //
import os.log
import UIKit
import AVKit import AVKit
import Combine import Combine
import CoreDataStack import CoreDataStack
import GameplayKit import GameplayKit
import os.log
import UIKit
final class PublicTimelineViewController: UIViewController, NeedsDependency, TimelinePostTableViewCellDelegate { final class PublicTimelineViewController: UIViewController, NeedsDependency, TimelinePostTableViewCellDelegate {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
var viewModel: PublicTimelineViewModel! var viewModel: PublicTimelineViewModel!
let refreshControl = UIRefreshControl()
lazy var tableView: UITableView = { lazy var tableView: UITableView = {
let tableView = UITableView() let tableView = UITableView()
tableView.register(TimelinePostTableViewCell.self, forCellReuseIdentifier: String(describing: TimelinePostTableViewCell.self)) tableView.register(TimelinePostTableViewCell.self, forCellReuseIdentifier: String(describing: TimelinePostTableViewCell.self))
@ -30,16 +30,30 @@ final class PublicTimelineViewController: UIViewController, NeedsDependency, Tim
}() }()
deinit { 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 { extension PublicTimelineViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.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.translatesAutoresizingMaskIntoConstraints = false
tableView.backgroundColor = Asset.Colors.tootDark.color tableView.backgroundColor = Asset.Colors.tootDark.color
view.addSubview(tableView) view.addSubview(tableView)
@ -60,6 +74,7 @@ extension PublicTimelineViewController {
timelinePostTableViewCellDelegate: self timelinePostTableViewCellDelegate: self
) )
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
viewModel.fetchLatest() viewModel.fetchLatest()
@ -67,7 +82,7 @@ extension PublicTimelineViewController {
.sink { completion in .sink { completion in
switch completion { switch completion {
case .failure(let error): 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: case .finished:
break break
} }
@ -77,12 +92,22 @@ extension PublicTimelineViewController {
} }
.store(in: &viewModel.disposeBag) .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 // MARK: - UITableViewDelegate
extension PublicTimelineViewController: UITableViewDelegate { extension PublicTimelineViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } guard let diffableDataSource = viewModel.diffableDataSource else { return 100 }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 }
@ -94,12 +119,9 @@ extension PublicTimelineViewController: UITableViewDelegate {
return ceil(frame.height) 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) { func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }

View File

@ -5,10 +5,10 @@
// Created by sxiaojian on 2021/1/27. // Created by sxiaojian on 2021/1/27.
// //
import os.log
import UIKit
import CoreData import CoreData
import CoreDataStack import CoreDataStack
import os.log
import UIKit
extension PublicTimelineViewModel { extension PublicTimelineViewModel {
func setupDiffableDataSource( func setupDiffableDataSource(
@ -20,26 +20,27 @@ extension PublicTimelineViewModel {
.autoconnect() .autoconnect()
.share() .share()
.eraseToAnyPublisher() .eraseToAnyPublisher()
diffableDataSource = TimelineSection.tableViewDiffableDataSource( diffableDataSource = TimelineSection.tableViewDiffableDataSource(
for: tableView, for: tableView,
dependency: dependency, dependency: dependency,
managedObjectContext: fetchedResultsController.managedObjectContext, managedObjectContext: fetchedResultsController.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher, timestampUpdatePublisher: timestampUpdatePublisher,
timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate) timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate
)
items.value = [] items.value = []
} }
} }
// MARK: - NSFetchedResultsControllerDelegate // MARK: - NSFetchedResultsControllerDelegate
extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, 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 indexes = tootIDs.value
let toots = fetchedResultsController.fetchedObjects ?? [] let toots = fetchedResultsController.fetchedObjects ?? []
guard toots.count == indexes.count else { return }
let items: [Item] = toots let items: [Item] = toots
.compactMap { toot -> (Int, Toot)? in .compactMap { toot -> (Int, Toot)? in
guard toot.deletedAt == nil else { return nil } guard toot.deletedAt == nil else { return nil }
@ -49,5 +50,4 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
.map { Item.toot(objectID: $0.1.objectID) } .map { Item.toot(objectID: $0.1.objectID) }
self.items.value = items self.items.value = items
} }
} }

View File

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

View File

@ -5,28 +5,39 @@
// Created by sxiaojian on 2021/1/27. // Created by sxiaojian on 2021/1/27.
// //
import os.log import AlamofireImage
import UIKit
import GameplayKit
import Combine import Combine
import CoreData import CoreData
import CoreDataStack import CoreDataStack
import GameplayKit
import MastodonSDK import MastodonSDK
import AlamofireImage import os.log
import UIKit
class PublicTimelineViewModel: NSObject { class PublicTimelineViewModel: NSObject {
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
// input // input
let context: AppContext let context: AppContext
let fetchedResultsController: NSFetchedResultsController<Toot> let fetchedResultsController: NSFetchedResultsController<Toot>
let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false)
weak var tableView: UITableView? weak var tableView: UITableView?
// output // output
var diffableDataSource: UITableViewDiffableDataSource<TimelineSection, Item>? var diffableDataSource: UITableViewDiffableDataSource<TimelineSection, Item>?
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 tootIDs = CurrentValueSubject<[String], Never>([])
let items = CurrentValueSubject<[Item], Never>([]) let items = CurrentValueSubject<[Item], Never>([])
var cellFrameCache = NSCache<NSNumber, NSValue>() var cellFrameCache = NSCache<NSNumber, NSValue>()
@ -49,7 +60,7 @@ class PublicTimelineViewModel: NSObject {
}() }()
super.init() super.init()
self.fetchedResultsController.delegate = self fetchedResultsController.delegate = self
items items
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
@ -57,7 +68,7 @@ class PublicTimelineViewModel: NSObject {
.sink { [weak self] items in .sink { [weak self] items in
guard let self = self else { return } guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource 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<TimelineSection, Item>() var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, Item>()
snapshot.appendSections([.main]) snapshot.appendSections([.main])
@ -82,14 +93,16 @@ class PublicTimelineViewModel: NSObject {
} }
deinit { 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 { extension PublicTimelineViewModel {
func fetchLatest() -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> { func fetchLatest() -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> {
return context.apiService.publicTimeline(count: 20, domain: "mstdn.jp") return context.apiService.publicTimeline(count: 20, domain: "mstdn.jp")
} }
func loadMore() -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> {
return context.apiService.publicTimeline(count: 20, domain: "mstdn.jp")
}
} }