feat: add bottomLoader

This commit is contained in:
sunxiaojian 2021-02-03 13:01:50 +08:00
parent 29439c9746
commit 04d4e7f33a
12 changed files with 211 additions and 22 deletions

View File

@ -22,6 +22,7 @@
2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; };
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; };
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; };
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; };
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; };
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */; };
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */; };
@ -33,6 +34,8 @@
2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0725C7E9A8004F19B8 /* Tag.swift */; };
2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0D25C7E9C9004F19B8 /* History.swift */; };
2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F1325C7EDD9004F19B8 /* Emoji.swift */; };
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; };
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; };
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.swift */; };
3533495136D843E85211E3E2 /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1B4523A7981F1044DE89C21 /* Pods_Mastodon_MastodonUITests.framework */; };
45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; };
@ -159,6 +162,7 @@
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>"; };
2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = "<group>"; };
2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewModel.swift; sourceTree = "<group>"; };
2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewController+StatusProvider.swift"; sourceTree = "<group>"; };
@ -170,6 +174,9 @@
2D927F0725C7E9A8004F19B8 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
2D927F0D25C7E9C9004F19B8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = "<group>"; };
2D927F1325C7EDD9004F19B8 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = "<group>"; };
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLoaderTableViewCell.swift; sourceTree = "<group>"; };
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = "<group>"; };
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = "<group>"; };
2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = "<group>"; };
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -352,6 +359,14 @@
path = Persist;
sourceTree = "<group>";
};
2D69CFF225CA9E2200C3A1B2 /* Protocol */ = {
isa = PBXGroup;
children = (
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */,
);
path = Protocol;
sourceTree = "<group>";
};
2D76316325C14BAC00929FB9 /* PublicTimeline */ = {
isa = PBXGroup;
children = (
@ -406,6 +421,8 @@
602D783BEC22881EBAD84419 /* Pods_Mastodon.framework */,
A1B4523A7981F1044DE89C21 /* Pods_Mastodon_MastodonUITests.framework */,
2D7631A725C1535600929FB9 /* TimelinePostTableViewCell.swift */,
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */,
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */,
);
path = TableviewCell;
sourceTree = "<group>";
@ -503,6 +520,7 @@
children = (
DB427DE325BAA00100D1B89D /* Info.plist */,
DB89BA1025C10FF5008580ED /* Mastodon.entitlements */,
2D69CFF225CA9E2200C3A1B2 /* Protocol */,
2D76319C25C151DE00929FB9 /* Diffiable */,
DB8AF52A25C13561002E6C99 /* State */,
2D61335525C1886800CAE157 /* Service */,
@ -676,6 +694,7 @@
isa = PBXGroup;
children = (
CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */,
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
@ -1045,6 +1064,7 @@
2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */,
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */,
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
@ -1073,10 +1093,12 @@
DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */,
2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */,
DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */,
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
DB8AF55725C137A8002E6C99 /* HomeViewController.swift in Sources */,
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
2D7631A825C1535600929FB9 /* TimelinePostTableViewCell.swift in Sources */,
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */,
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */,
DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */,
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,

View File

@ -5,16 +5,17 @@
// Created by sxiaojian on 2021/1/27.
//
import Foundation
import CoreData
import MastodonSDK
import CoreDataStack
import Foundation
import MastodonSDK
/// Note: update Equatable when change case
enum Item {
// normal list
case toot(objectID: NSManagedObjectID)
case bottomLoader
}
extension Item: Equatable {
@ -22,6 +23,8 @@ extension Item: Equatable {
switch (lhs, rhs) {
case (.toot(let objectIDLeft), .toot(let objectIDRight)):
return objectIDLeft == objectIDRight
default:
return false
}
}
}
@ -31,7 +34,8 @@ extension Item: Hashable {
switch self {
case .toot(let objectID):
hasher.combine(objectID)
case .bottomLoader:
hasher.combine(String(describing: Item.bottomLoader.self))
}
}
}

View File

@ -37,6 +37,10 @@ extension TimelineSection {
}
cell.delegate = timelinePostTableViewCellDelegate
return cell
case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.activityIndicatorView.startAnimating()
return cell
}
}
}

View File

@ -10,6 +10,15 @@ import Foundation
// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length
// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
internal enum L10n {
internal enum Common {
internal enum Controls {
internal enum Timeline {
/// Load More
internal static let loadMore = L10n.tr("Localizable", "Common.Controls.Timeline.LoadMore")
}
}
}
}
// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length
// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces

View File

@ -0,0 +1,42 @@
//
// LoadMoreConfigurableTableViewContainer.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/3.
//
import UIKit
import GameplayKit
/// The tableView container driven by state machines with "LoadMore" logic
protocol LoadMoreConfigurableTableViewContainer: UIViewController {
associatedtype BottomLoaderTableViewCell: UITableViewCell
associatedtype LoadingState: GKState
var loadMoreConfigurableTableView: UITableView { get }
var loadMoreConfigurableStateMachine: GKStateMachine { get }
func handleScrollViewDidScroll(_ scrollView: UIScrollView)
}
extension LoadMoreConfigurableTableViewContainer {
func handleScrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView === loadMoreConfigurableTableView else { return }
let cells = loadMoreConfigurableTableView.visibleCells.compactMap { $0 as? BottomLoaderTableViewCell }
guard let loaderTableViewCell = cells.first else { return }
if let tabBar = tabBarController?.tabBar, let window = view.window {
let loaderTableViewCellFrameInWindow = loadMoreConfigurableTableView.convert(loaderTableViewCell.frame, to: nil)
let windowHeight = window.frame.height
let loaderAppear = (loaderTableViewCellFrameInWindow.origin.y + 0.8 * loaderTableViewCell.frame.height) < (windowHeight - tabBar.frame.height)
if loaderAppear {
loadMoreConfigurableStateMachine.enter(LoadingState.self)
} else {
// do nothing
}
} else {
loadMoreConfigurableStateMachine.enter(LoadingState.self)
}
}
}

View File

@ -5,3 +5,4 @@
Created by MainasuK Cirno on 2021/1/22.
*/
"Common.Controls.Timeline.LoadMore" = "Load More";

View File

@ -38,6 +38,8 @@ extension PublicTimelineViewController {
let toot = managedObjectContext.object(with: objectID) as? Toot
promise(.success(toot))
}
default:
promise(.success(nil))
}
}
}

View File

@ -24,6 +24,7 @@ final class PublicTimelineViewController: UIViewController, NeedsDependency, Tim
lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.register(TimelinePostTableViewCell.self, forCellReuseIdentifier: String(describing: TimelinePostTableViewCell.self))
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none
return tableView
@ -77,23 +78,18 @@ extension PublicTimelineViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
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)
case .finished:
break
}
} receiveValue: { response in
let tootsIDs = response.value.map { $0.id }
self.viewModel.tootIDs.value = tootsIDs
}
.store(in: &viewModel.disposeBag)
viewModel.stateMachine.enter(PublicTimelineViewModel.State.Loading.self)
}
}
// MARK: - UIScrollViewDelegate
extension PublicTimelineViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
handleScrollViewDidScroll(scrollView)
}
}
// MARK: - Selector
extension PublicTimelineViewController {
@ -130,3 +126,11 @@ extension PublicTimelineViewController: UITableViewDelegate {
viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key))
}
}
// MARK: - LoadMoreConfigurableTableViewContainer
extension PublicTimelineViewController: LoadMoreConfigurableTableViewContainer {
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
typealias LoadingState = PublicTimelineViewModel.State.LoadingMore
var loadMoreConfigurableTableView: UITableView { return tableView }
var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine }
}

View File

@ -121,10 +121,16 @@ extension PublicTimelineViewModel.State {
break
}
} receiveValue: { response in
viewModel.isFetchingLatestTimeline.value = false
let tootsIDs = response.value.map { $0.id }
viewModel.tootIDs.value = tootsIDs
stateMachine.enter(Idle.self)
var oldTootsIDs = viewModel.tootIDs.value
for toot in response.value {
if !oldTootsIDs.contains(toot.id) {
oldTootsIDs.append(toot.id)
}
}
viewModel.tootIDs.value = oldTootsIDs
}
.store(in: &viewModel.disposeBag)
}

View File

@ -73,7 +73,14 @@ class PublicTimelineViewModel: NSObject {
var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
if let currentState = self.stateMachine.currentState {
switch currentState {
case is State.Idle, is State.LoadingMore, is State.Fail:
snapshot.appendItems([.bottomLoader], toSection: .main)
default:
break
}
}
diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty)
}
.store(in: &disposeBag)

View File

@ -0,0 +1,18 @@
//
// TimelineBottomLoaderTableViewCell.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/3.
//
import UIKit
import Combine
final class TimelineBottomLoaderTableViewCell: TimelineLoaderTableViewCell {
override func _init() {
super._init()
activityIndicatorView.isHidden = false
activityIndicatorView.startAnimating()
}
}

View File

@ -0,0 +1,70 @@
//
// TimelineLoaderTableViewCell.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/3.
//
import UIKit
import Combine
class TimelineLoaderTableViewCell: UITableViewCell {
static let cellHeight: CGFloat = 48
var disposeBag = Set<AnyCancellable>()
let loadMoreButton: UIButton = {
let button = UIButton(type: .system)
button.titleLabel?.font = .preferredFont(forTextStyle: .headline)
button.setTitle(L10n.Common.Controls.Timeline.loadMore, for: .normal)
return button
}()
let activityIndicatorView: UIActivityIndicatorView = {
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
activityIndicatorView.tintColor = .white
activityIndicatorView.hidesWhenStopped = true
return activityIndicatorView
}()
override func prepareForReuse() {
super.prepareForReuse()
disposeBag.removeAll()
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
func _init() {
selectionStyle = .none
backgroundColor = Asset.Colors.tootDark.color
loadMoreButton.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(loadMoreButton)
NSLayoutConstraint.activate([
loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
loadMoreButton.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
contentView.readableContentGuide.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor, constant: 8),
loadMoreButton.heightAnchor.constraint(equalToConstant: TimelineLoaderTableViewCell.cellHeight - 2 * 8).priority(.defaultHigh),
])
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
addSubview(activityIndicatorView)
NSLayoutConstraint.activate([
activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor),
activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor),
])
loadMoreButton.isHidden = true
activityIndicatorView.isHidden = true
}
}