feat: add pull to refresh
This commit is contained in:
parent
3e1d2bcc16
commit
29439c9746
|
@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -357,6 +359,7 @@
|
|||
2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */,
|
||||
2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */,
|
||||
2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */,
|
||||
2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */,
|
||||
);
|
||||
path = PublicTimeline;
|
||||
sourceTree = "<group>";
|
||||
|
@ -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 */,
|
||||
|
|
|
@ -5,21 +5,21 @@
|
|||
// 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<AnyCancellable>()
|
||||
var viewModel: PublicTimelineViewModel!
|
||||
|
||||
let refreshControl = UIRefreshControl()
|
||||
|
||||
lazy var tableView: UITableView = {
|
||||
let tableView = UITableView()
|
||||
|
@ -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 {
|
||||
|
||||
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 }
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
@ -26,19 +26,20 @@ extension PublicTimelineViewModel {
|
|||
dependency: dependency,
|
||||
managedObjectContext: fetchedResultsController.managedObjectContext,
|
||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||
timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate)
|
||||
timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate
|
||||
)
|
||||
items.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSFetchedResultsControllerDelegate
|
||||
|
||||
extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
|
||||
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 toots = fetchedResultsController.fetchedObjects ?? []
|
||||
guard toots.count == indexes.count else { return }
|
||||
|
||||
let items: [Item] = toots
|
||||
.compactMap { toot -> (Int, Toot)? in
|
||||
|
@ -49,5 +50,4 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
|
|||
.map { Item.toot(objectID: $0.1.objectID) }
|
||||
self.items.value = items
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
let fetchedResultsController: NSFetchedResultsController<Toot>
|
||||
let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false)
|
||||
weak var tableView: UITableView?
|
||||
|
||||
// output
|
||||
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 items = CurrentValueSubject<[Item], Never>([])
|
||||
var cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||
|
@ -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<TimelineSection, Item>()
|
||||
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<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue