diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 0c8d53cda..ebbffe843 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -11,6 +11,9 @@ 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; }; 2D152A8C25C295CC009AA50C /* TimelinePostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* TimelinePostView.swift */; }; 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; }; + 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; }; + 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; }; + 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */; }; 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 2D42FF6025C8177C004A627A /* ActiveLabel */; }; 2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF6A25C817D2004A627A /* MastodonContent.swift */; }; 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; }; @@ -155,6 +158,9 @@ 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = ""; }; 2D152A8B25C295CC009AA50C /* TimelinePostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePostView.swift; sourceTree = ""; }; 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; + 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = ""; }; + 2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; + 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; 2D42FF6A25C817D2004A627A /* MastodonContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonContent.swift; sourceTree = ""; }; 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionToolBarContainer.swift; sourceTree = ""; }; 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTestExpandedButton.swift; sourceTree = ""; }; @@ -375,6 +381,7 @@ 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */, 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */, 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */, + 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */, 2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */, 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */, ); @@ -423,6 +430,7 @@ 2D7631A725C1535600929FB9 /* TimelinePostTableViewCell.swift */, 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */, 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */, + 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */, ); path = TableviewCell; sourceTree = ""; @@ -698,6 +706,7 @@ DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */, 2D42FF8E25C8228A004A627A /* UIButton.swift */, DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */, + 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, ); path = Extension; sourceTree = ""; @@ -1092,6 +1101,7 @@ DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, + 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, 2D76319F25C1521200929FB9 /* TimelineSection.swift in Sources */, @@ -1101,6 +1111,7 @@ DB98338725C945ED00AD9700 /* Strings.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, + 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, @@ -1117,6 +1128,7 @@ DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, + 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index 3113bc5e7..31db137b7 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -15,6 +15,8 @@ enum Item { // normal list case toot(objectID: NSManagedObjectID) + // loader + case middleLoader(tootID: String) case bottomLoader } @@ -25,6 +27,8 @@ extension Item: Equatable { return objectIDLeft == objectIDRight case (.bottomLoader, .bottomLoader): return true + case (.middleLoader(let upperLeft), .middleLoader(let upperRight)): + return upperLeft == upperRight default: return false } @@ -36,6 +40,9 @@ extension Item: Hashable { switch self { case .toot(let objectID): hasher.combine(objectID) + case .middleLoader(let upper): + hasher.combine(String(describing: Item.middleLoader.self)) + hasher.combine(upper) case .bottomLoader: hasher.combine(String(describing: Item.bottomLoader.self)) } diff --git a/Mastodon/Diffiable/Section/TimelineSection.swift b/Mastodon/Diffiable/Section/TimelineSection.swift index 50fbdd520..1d8406c94 100644 --- a/Mastodon/Diffiable/Section/TimelineSection.swift +++ b/Mastodon/Diffiable/Section/TimelineSection.swift @@ -21,9 +21,10 @@ extension TimelineSection { dependency: NeedsDependency, managedObjectContext: NSManagedObjectContext, timestampUpdatePublisher: AnyPublisher, - timelinePostTableViewCellDelegate: TimelinePostTableViewCellDelegate + timelinePostTableViewCellDelegate: TimelinePostTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { [weak timelinePostTableViewCellDelegate] tableView, indexPath, item -> UITableViewCell? in + UITableViewDiffableDataSource(tableView: tableView) { [weak timelinePostTableViewCellDelegate, weak timelineMiddleLoaderTableViewCellDelegate] tableView, indexPath, item -> UITableViewCell? in guard let timelinePostTableViewCellDelegate = timelinePostTableViewCellDelegate else { return UITableViewCell() } switch item { @@ -33,10 +34,15 @@ extension TimelineSection { // configure cell managedObjectContext.performAndWait { let toot = managedObjectContext.object(with: objectID) as! Toot - TimelineSection.configure(cell: cell,timestampUpdatePublisher: timestampUpdatePublisher, toot: toot) + TimelineSection.configure(cell: cell, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot) } cell.delegate = timelinePostTableViewCellDelegate return cell + case .middleLoader(let upperTimelineTootID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell + cell.delegate = timelineMiddleLoaderTableViewCellDelegate + timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineTootID: upperTimelineTootID) + return cell case .bottomLoader: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell cell.activityIndicatorView.startAnimating() diff --git a/Mastodon/Extension/UIView.swift b/Mastodon/Extension/UIView.swift new file mode 100644 index 000000000..ee09a2787 --- /dev/null +++ b/Mastodon/Extension/UIView.swift @@ -0,0 +1,26 @@ +// +// UIView.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/4. +// + +import UIKit + +extension UIView { + + static var separatorLine: UIView { + let line = UIView() + line.backgroundColor = .separator + return line + } + + static func separatorLineHeight(of view: UIView) -> CGFloat { + return 1.0 / view.traitCollection.displayScale + } + + static var floatyButtonBottomMargin: CGFloat { + return 16 + } + +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index c5b25beb6..af3dd2485 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -22,6 +22,9 @@ internal typealias AssetImageTypeAlias = ImageAsset.Image // swiftlint:disable identifier_name line_length nesting type_body_length type_name internal enum Asset { internal static let accentColor = ColorAsset(name: "AccentColor") + internal enum Arrows { + internal static let arrowTriangle2Circlepath = ImageAsset(name: "Arrows/arrow.triangle.2.circlepath") + } internal enum Colors { internal static let tootDark = ColorAsset(name: "Colors/Toot.Dark") internal static let tootGray = ColorAsset(name: "Colors/Toot.Gray") diff --git a/Mastodon/Resources/Assets.xcassets/Arrows/Contents.json b/Mastodon/Resources/Assets.xcassets/Arrows/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Arrows/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/Contents.json new file mode 100644 index 000000000..0ee766f71 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "arrow.triangle.2.circlepath.pdf", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/arrow.triangle.2.circlepath.pdf b/Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/arrow.triangle.2.circlepath.pdf new file mode 100644 index 000000000..b864ab380 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/arrow.triangle.2.circlepath.pdf @@ -0,0 +1,193 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 4.000000 10.752930 cm +0.000000 0.000000 0.000000 scn +15.009519 2.109471 m +15.085540 1.562444 15.590621 1.180617 16.137648 1.256639 c +16.684677 1.332660 17.066502 1.837741 16.990480 2.384768 c +15.009519 2.109471 l +h +-0.423099 4.631682 m +-0.635487 4.121869 -0.394376 3.536408 0.115438 3.324021 c +0.625251 3.111633 1.210711 3.352744 1.423099 3.862558 c +-0.423099 4.631682 l +h +1.000000 8.247120 m +1.000000 8.799404 0.552285 9.247120 0.000000 9.247120 c +-0.552285 9.247120 -1.000000 8.799404 -1.000000 8.247120 c +1.000000 8.247120 l +h +0.000000 4.247120 m +-1.000000 4.247120 l +-1.000000 3.694835 -0.552285 3.247120 0.000000 3.247120 c +0.000000 4.247120 l +h +4.000000 3.247120 m +4.552285 3.247120 5.000000 3.694835 5.000000 4.247120 c +5.000000 4.799405 4.552285 5.247120 4.000000 5.247120 c +4.000000 3.247120 l +h +16.990480 2.384768 m +16.715729 4.361807 15.798570 6.193669 14.380284 7.598174 c +12.972991 6.177073 l +14.079566 5.081251 14.795152 3.651996 15.009519 2.109471 c +16.990480 2.384768 l +h +14.380284 7.598174 m +12.961998 9.002679 11.121269 9.901910 9.141643 10.157345 c +8.885699 8.173789 l +10.430243 7.974494 11.866417 7.272897 12.972991 6.177073 c +14.380284 7.598174 l +h +9.141643 10.157345 m +7.162015 10.412781 5.153316 10.010252 3.424967 9.011765 c +4.425436 7.279984 l +5.773929 8.059025 7.341156 8.373085 8.885699 8.173789 c +9.141643 10.157345 l +h +3.424967 9.011765 m +1.696617 8.013276 0.344502 6.474223 -0.423099 4.631682 c +1.423099 3.862558 l +2.021996 5.300145 3.076944 6.500945 4.425436 7.279984 c +3.424967 9.011765 l +h +-1.000000 8.247120 m +-1.000000 4.247120 l +1.000000 4.247120 l +1.000000 8.247120 l +-1.000000 8.247120 l +h +0.000000 3.247120 m +4.000000 3.247120 l +4.000000 5.247120 l +0.000000 5.247120 l +0.000000 3.247120 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 4.000000 1.767822 cm +0.000000 0.000000 0.000000 scn +0.990481 9.369826 m +0.914460 9.916854 0.409379 10.298680 -0.137649 10.222659 c +-0.684676 10.146638 -1.066502 9.641557 -0.990481 9.094529 c +0.990481 9.369826 l +h +16.423100 6.847616 m +16.635487 7.357429 16.394375 7.942889 15.884562 8.155277 c +15.374748 8.367664 14.789289 8.126554 14.576900 7.616740 c +16.423100 6.847616 l +h +15.000000 3.232178 m +15.000000 2.679893 15.447715 2.232178 16.000000 2.232178 c +16.552284 2.232178 17.000000 2.679893 17.000000 3.232178 c +15.000000 3.232178 l +h +16.000000 7.232178 m +17.000000 7.232178 l +17.000000 7.784462 16.552284 8.232178 16.000000 8.232178 c +16.000000 7.232178 l +h +12.000000 8.232178 m +11.447715 8.232178 11.000000 7.784462 11.000000 7.232178 c +11.000000 6.679893 11.447715 6.232178 12.000000 6.232178 c +12.000000 8.232178 l +h +-0.990481 9.094529 m +-0.715729 7.117491 0.201429 5.285628 1.619715 3.881123 c +3.027008 5.302223 l +1.920433 6.398046 1.204848 7.827302 0.990481 9.369826 c +-0.990481 9.094529 l +h +1.619715 3.881123 m +3.038001 2.476617 4.878731 1.577388 6.858358 1.321952 c +7.114300 3.305508 l +5.569757 3.504804 4.133582 4.206400 3.027008 5.302223 c +1.619715 3.881123 l +h +6.858358 1.321952 m +8.837985 1.066517 10.846684 1.469046 12.575033 2.467534 c +11.574564 4.199314 l +10.226071 3.420273 8.658844 3.106212 7.114300 3.305508 c +6.858358 1.321952 l +h +12.575033 2.467534 m +14.303383 3.466022 15.655499 5.005074 16.423100 6.847616 c +14.576900 7.616740 l +13.978004 6.179152 12.923057 4.978354 11.574564 4.199314 c +12.575033 2.467534 l +h +17.000000 3.232178 m +17.000000 7.232178 l +15.000000 7.232178 l +15.000000 3.232178 l +17.000000 3.232178 l +h +16.000000 8.232178 m +12.000000 8.232178 l +12.000000 6.232178 l +16.000000 6.232178 l +16.000000 8.232178 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 3597 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000003687 00000 n +0000003710 00000 n +0000003883 00000 n +0000003957 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +4016 +%%EOF \ No newline at end of file diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index 73c5c7835..cb8b67f12 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -7,6 +7,7 @@ import AVKit import Combine +import CoreData import CoreDataStack import GameplayKit import os.log @@ -24,6 +25,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(TimelineMiddleLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self)) tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none @@ -72,7 +74,8 @@ extension PublicTimelineViewController { viewModel.setupDiffableDataSource( for: tableView, dependency: self, - timelinePostTableViewCellDelegate: self + timelinePostTableViewCellDelegate: self, + timelineMiddleLoaderTableViewCellDelegate: self ) } @@ -127,3 +130,66 @@ extension PublicTimelineViewController: LoadMoreConfigurableTableViewContainer { var loadMoreConfigurableTableView: UITableView { return tableView } var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } } + +// MARK: - TimelineMiddleLoaderTableViewCellDelegate +extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { + + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String) { + viewModel.loadMiddleSateMachineList + .receive(on: DispatchQueue.main) + .sink { [weak self] ids in + guard let _ = self else { return } + if let stateMachine = ids[upperTimelineTootID] { + guard let state = stateMachine.currentState else { + assertionFailure() + return + } + + // make success state same as loading due to snapshot updating delay + let isLoading = state is PublicTimelineViewModel.LoadMiddleState.Loading || state is PublicTimelineViewModel.LoadMiddleState.Success + cell.loadMoreButton.isHidden = isLoading + if isLoading { + cell.activityIndicatorView.startAnimating() + } else { + cell.activityIndicatorView.stopAnimating() + } + } else { + cell.loadMoreButton.isHidden = false + cell.activityIndicatorView.stopAnimating() + } + } + .store(in: &cell.disposeBag) + + var dict = viewModel.loadMiddleSateMachineList.value + if let _ = dict[upperTimelineTootID] { + // do nothing + } else { + let stateMachine = GKStateMachine(states: [ + PublicTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID), + PublicTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID), + PublicTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID), + PublicTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID), + ]) + stateMachine.enter(PublicTimelineViewModel.LoadMiddleState.Initial.self) + dict[upperTimelineTootID] = stateMachine + viewModel.loadMiddleSateMachineList.value = dict + } + } + + func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + + switch item { + case .middleLoader(let upper): + guard let stateMachine = viewModel.loadMiddleSateMachineList.value[upper] else { + assertionFailure() + return + } + stateMachine.enter(PublicTimelineViewModel.LoadMiddleState.Loading.self) + default: + assertionFailure() + } + } +} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index a2766a00f..13f3eeaa3 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -14,7 +14,8 @@ extension PublicTimelineViewModel { func setupDiffableDataSource( for tableView: UITableView, dependency: NeedsDependency, - timelinePostTableViewCellDelegate: TimelinePostTableViewCellDelegate + timelinePostTableViewCellDelegate: TimelinePostTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? ) { let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) .autoconnect() @@ -26,7 +27,8 @@ extension PublicTimelineViewModel { dependency: dependency, managedObjectContext: fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, - timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate + timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate ) items.value = [] stateMachine.enter(PublicTimelineViewModel.State.Loading.self) @@ -42,13 +44,20 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { let indexes = tootIDs.value let toots = fetchedResultsController.fetchedObjects ?? [] guard toots.count == indexes.count else { return } - let items: [Item] = toots + let indexTootTuples: [(Int, Toot)] = toots .compactMap { toot -> (Int, Toot)? in guard toot.deletedAt == nil else { return nil } return indexes.firstIndex(of: toot.id).map { index in (index, toot) } } .sorted { $0.0 < $1.0 } - .map { Item.toot(objectID: $0.1.objectID) } + var items = [Item]() + for tuple in indexTootTuples { + if tootIDsWhichHasGap.contains(tuple.1.id) { + items.append(Item.middleLoader(tootID: tuple.1.id)) + } + items.append(Item.toot(objectID: tuple.1.objectID)) + } + self.items.value = items } } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift new file mode 100644 index 000000000..69050bf57 --- /dev/null +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift @@ -0,0 +1,116 @@ +// +// PublicTimelineViewModel+LoadMiddleState.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/4. +// + +import CoreData +import CoreDataStack +import Foundation +import GameplayKit +import os.log + +extension PublicTimelineViewModel { + class LoadMiddleState: GKState { + weak var viewModel: PublicTimelineViewModel? + let upperTimelineTootID: String + + init(viewModel: PublicTimelineViewModel, upperTimelineTootID: String) { + self.viewModel = viewModel + self.upperTimelineTootID = upperTimelineTootID + } + + 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) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + var dict = viewModel.loadMiddleSateMachineList.value + dict[self.upperTimelineTootID] = stateMachine + viewModel.loadMiddleSateMachineList.value = dict // trigger value change + } + } +} + +extension PublicTimelineViewModel.LoadMiddleState { + class Initial: PublicTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class Loading: PublicTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // guard let viewModel = viewModel else { return false } + return stateClass == Success.self || stateClass == Fail.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + let maxID = upperTimelineTootID + viewModel.context.apiService.publicTimeline( + domain: activeMastodonAuthenticationBox.domain, + maxID: maxID + ) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: fetch tweets failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + break + } + } receiveValue: { response in + let toots = response.value + let addedToots = toots.filter { !viewModel.tootIDs.value.contains($0.id) } + + guard let gapIndex = viewModel.tootIDs.value.firstIndex(of: self.upperTimelineTootID) else { return } + let upToots = Array(viewModel.tootIDs.value[0...(gapIndex-1)]) + let downToots = Array(viewModel.tootIDs.value[gapIndex...viewModel.tootIDs.value.count-1]) + + // construct newTootIDs + var newTootIDs = upToots + newTootIDs.append(contentsOf: addedToots.map { $0.id }) + newTootIDs.append(contentsOf: downToots) + // remove old gap from viewmodel + if let index = viewModel.tootIDsWhichHasGap.firstIndex(of: self.upperTimelineTootID) { + viewModel.tootIDsWhichHasGap.remove(at: index) + } + // add new gap from viewmodel if need + let intersection = toots.filter { upToots.contains($0.id) } + if intersection.isEmpty { + toots.first.flatMap { viewModel.tootIDsWhichHasGap.append($0.id) } + } + + viewModel.tootIDs.value = newTootIDs + os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld tweets, %{public}%ld new tweets", (#file as NSString).lastPathComponent, #line, #function, toots.count, addedToots.count) + if addedToots.isEmpty { + stateMachine.enter(Fail.self) + } else { + stateMachine.enter(Success.self) + } + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: PublicTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // guard let viewModel = viewModel else { return false } + return stateClass == Loading.self + } + } + + class Success: PublicTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // guard let viewModel = viewModel else { return false } + return false + } + } +} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift index 04bb4ec54..6b3c8d3db 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift @@ -69,8 +69,20 @@ extension PublicTimelineViewModel.State { } } receiveValue: { response in viewModel.isFetchingLatestTimeline.value = false - let tootsIDs = response.value.map { $0.id } - viewModel.tootIDs.value = tootsIDs + var newTootsIDs = response.value.compactMap { $0.id } + let oldTootsIDs = viewModel.tootIDs.value + var hasGap = true + for tootID in oldTootsIDs { + if !newTootsIDs.contains(tootID) { + newTootsIDs.append(tootID) + } else { + hasGap = false + } + } + if hasGap { + oldTootsIDs.first.flatMap { viewModel.tootIDsWhichHasGap.append($0) } + } + viewModel.tootIDs.value = newTootsIDs stateMachine.enter(Idle.self) } .store(in: &viewModel.disposeBag) @@ -149,7 +161,6 @@ extension PublicTimelineViewModel.State { } viewModel.tootIDs.value = oldTootsIDs - } .store(in: &viewModel.disposeBag) } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift index fda4a2409..369d771e8 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift @@ -20,9 +20,15 @@ class PublicTimelineViewModel: NSObject { // input let context: AppContext let fetchedResultsController: NSFetchedResultsController - let isFetchingLatestTimeline = CurrentValueSubject(false) - weak var tableView: UITableView? + let isFetchingLatestTimeline = CurrentValueSubject(false) + + // middle loader + let loadMiddleSateMachineList = CurrentValueSubject<[String: GKStateMachine], Never>([:]) + + weak var tableView: UITableView? + // + var tootIDsWhichHasGap = [String]() // output var diffableDataSource: UITableViewDiffableDataSource? @@ -104,10 +110,3 @@ class PublicTimelineViewModel: NSObject { os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) } } - -extension PublicTimelineViewModel { - - func loadMore() -> AnyPublisher, Error> { - return context.apiService.publicTimeline(domain: "mstdn.jp") - } -} diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift new file mode 100644 index 000000000..8f566dc87 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift @@ -0,0 +1,48 @@ +// +// TimelineMiddleLoaderTableViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/4. +// + +import Combine +import CoreData +import os.log +import UIKit + +protocol TimelineMiddleLoaderTableViewCellDelegate: class { + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String) + func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) +} + +final class TimelineMiddleLoaderTableViewCell: TimelineLoaderTableViewCell { + weak var delegate: TimelineMiddleLoaderTableViewCellDelegate? + + override func _init() { + super._init() + + backgroundColor = Asset.Colors.tootDark.color + + let separatorLine = UIView.separatorLine + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + NSLayoutConstraint.activate([ + separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: separatorLine.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: separatorLine.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: separatorLine)) + ]) + + loadMoreButton.isHidden = false + loadMoreButton.setImage(Asset.Arrows.arrowTriangle2Circlepath.image.withRenderingMode(.alwaysTemplate), for: .normal) + loadMoreButton.setInsets(forContentPadding: .zero, imageTitlePadding: 4) + loadMoreButton.addTarget(self, action: #selector(TimelineMiddleLoaderTableViewCell.loadMoreButtonDidPressed(_:)), for: .touchUpInside) + } +} + +extension TimelineMiddleLoaderTableViewCell { + @objc private func loadMoreButtonDidPressed(_ sender: UIButton) { + os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) + delegate?.timelineMiddleLoaderTableViewCell(self, loadMoreButtonDidPressed: sender) + } +}