Merge pull request #8 from tootsuite/feature/MiddleLoader

feat: add MiddleLoader
This commit is contained in:
sxiaojian88 2021-02-05 17:10:33 +08:00 committed by GitHub
commit a180f62daf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 622 additions and 18 deletions

View File

@ -11,6 +11,10 @@
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; }; 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; };
2D152A8C25C295CC009AA50C /* TimelinePostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* TimelinePostView.swift */; }; 2D152A8C25C295CC009AA50C /* TimelinePostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* TimelinePostView.swift */; };
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.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 */; };
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */; };
2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 2D42FF6025C8177C004A627A /* ActiveLabel */; }; 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 2D42FF6025C8177C004A627A /* ActiveLabel */; };
2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF6A25C817D2004A627A /* MastodonContent.swift */; }; 2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF6A25C817D2004A627A /* MastodonContent.swift */; };
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; }; 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; };
@ -155,6 +159,10 @@
2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = "<group>"; }; 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = "<group>"; };
2D152A8B25C295CC009AA50C /* TimelinePostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePostView.swift; sourceTree = "<group>"; }; 2D152A8B25C295CC009AA50C /* TimelinePostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePostView.swift; sourceTree = "<group>"; };
2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; }; 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = "<group>"; };
2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = "<group>"; };
2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = "<group>"; };
2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentOffsetAdjustableTimelineViewControllerDelegate.swift; sourceTree = "<group>"; };
2D42FF6A25C817D2004A627A /* MastodonContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonContent.swift; sourceTree = "<group>"; }; 2D42FF6A25C817D2004A627A /* MastodonContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonContent.swift; sourceTree = "<group>"; };
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>"; };
@ -365,6 +373,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */, 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */,
2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */,
); );
path = Protocol; path = Protocol;
sourceTree = "<group>"; sourceTree = "<group>";
@ -375,6 +384,7 @@
2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */, 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */,
2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */, 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */,
2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */, 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */,
2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */,
2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */, 2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */,
2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */, 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */,
); );
@ -423,6 +433,7 @@
2D7631A725C1535600929FB9 /* TimelinePostTableViewCell.swift */, 2D7631A725C1535600929FB9 /* TimelinePostTableViewCell.swift */,
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */, 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */,
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */, 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */,
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */,
); );
path = TableviewCell; path = TableviewCell;
sourceTree = "<group>"; sourceTree = "<group>";
@ -698,6 +709,7 @@
DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */, DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */,
2D42FF8E25C8228A004A627A /* UIButton.swift */, 2D42FF8E25C8228A004A627A /* UIButton.swift */,
DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */, DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */,
2D32EAB925CB9B0500C9ED86 /* UIView.swift */,
); );
path = Extension; path = Extension;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1090,8 +1102,10 @@
2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */,
DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */,
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */,
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */,
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */, DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */,
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
2D76319F25C1521200929FB9 /* TimelineSection.swift in Sources */, 2D76319F25C1521200929FB9 /* TimelineSection.swift in Sources */,
@ -1101,6 +1115,7 @@
DB98338725C945ED00AD9700 /* Strings.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */,
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */,
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */,
2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */,
DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */,
DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */,
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */,
@ -1117,6 +1132,7 @@
DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */, DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */,
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */,
DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;

View File

@ -15,6 +15,8 @@ enum Item {
// normal list // normal list
case toot(objectID: NSManagedObjectID) case toot(objectID: NSManagedObjectID)
// loader
case middleLoader(tootID: String)
case bottomLoader case bottomLoader
} }
@ -25,6 +27,8 @@ extension Item: Equatable {
return objectIDLeft == objectIDRight return objectIDLeft == objectIDRight
case (.bottomLoader, .bottomLoader): case (.bottomLoader, .bottomLoader):
return true return true
case (.middleLoader(let upperLeft), .middleLoader(let upperRight)):
return upperLeft == upperRight
default: default:
return false return false
} }
@ -36,6 +40,9 @@ extension Item: Hashable {
switch self { switch self {
case .toot(let objectID): case .toot(let objectID):
hasher.combine(objectID) hasher.combine(objectID)
case .middleLoader(let upper):
hasher.combine(String(describing: Item.middleLoader.self))
hasher.combine(upper)
case .bottomLoader: case .bottomLoader:
hasher.combine(String(describing: Item.bottomLoader.self)) hasher.combine(String(describing: Item.bottomLoader.self))
} }

View File

@ -21,9 +21,10 @@ extension TimelineSection {
dependency: NeedsDependency, dependency: NeedsDependency,
managedObjectContext: NSManagedObjectContext, managedObjectContext: NSManagedObjectContext,
timestampUpdatePublisher: AnyPublisher<Date, Never>, timestampUpdatePublisher: AnyPublisher<Date, Never>,
timelinePostTableViewCellDelegate: TimelinePostTableViewCellDelegate timelinePostTableViewCellDelegate: TimelinePostTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
) -> UITableViewDiffableDataSource<TimelineSection, Item> { ) -> UITableViewDiffableDataSource<TimelineSection, Item> {
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() } guard let timelinePostTableViewCellDelegate = timelinePostTableViewCellDelegate else { return UITableViewCell() }
switch item { switch item {
@ -33,10 +34,15 @@ extension TimelineSection {
// configure cell // configure cell
managedObjectContext.performAndWait { managedObjectContext.performAndWait {
let toot = managedObjectContext.object(with: objectID) as! Toot 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 cell.delegate = timelinePostTableViewCellDelegate
return cell 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: case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.activityIndicatorView.startAnimating() cell.activityIndicatorView.startAnimating()

View File

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

View File

@ -22,6 +22,9 @@ internal typealias AssetImageTypeAlias = ImageAsset.Image
// swiftlint:disable identifier_name line_length nesting type_body_length type_name // swiftlint:disable identifier_name line_length nesting type_body_length type_name
internal enum Asset { internal enum Asset {
internal static let accentColor = ColorAsset(name: "AccentColor") 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 enum Colors {
internal static let tootDark = ColorAsset(name: "Colors/Toot.Dark") internal static let tootDark = ColorAsset(name: "Colors/Toot.Dark")
internal static let tootGray = ColorAsset(name: "Colors/Toot.Gray") internal static let tootGray = ColorAsset(name: "Colors/Toot.Gray")

View File

@ -0,0 +1,13 @@
//
// ContentOffsetAdjustableTimelineViewControllerDelegate.swift
// Mastodon
//
// Created by sxiaojian on 2021/2/5.
//
import UIKit
protocol ContentOffsetAdjustableTimelineViewControllerDelegate: class {
func navigationBar() -> UINavigationBar?
}

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

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

View File

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

View File

@ -7,6 +7,7 @@
import AVKit import AVKit
import Combine import Combine
import CoreData
import CoreDataStack import CoreDataStack
import GameplayKit import GameplayKit
import os.log import os.log
@ -24,6 +25,7 @@ final class PublicTimelineViewController: UIViewController, NeedsDependency, Tim
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))
tableView.register(TimelineMiddleLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self))
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
tableView.rowHeight = UITableView.automaticDimension tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none tableView.separatorStyle = .none
@ -68,11 +70,13 @@ extension PublicTimelineViewController {
]) ])
viewModel.tableView = tableView viewModel.tableView = tableView
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
tableView.delegate = self tableView.delegate = self
viewModel.setupDiffableDataSource( viewModel.setupDiffableDataSource(
for: tableView, for: tableView,
dependency: self, dependency: self,
timelinePostTableViewCellDelegate: self timelinePostTableViewCellDelegate: self,
timelineMiddleLoaderTableViewCellDelegate: self
) )
} }
@ -119,6 +123,14 @@ extension PublicTimelineViewController: UITableViewDelegate {
viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key)) viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key))
} }
} }
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
extension PublicTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate {
func navigationBar() -> UINavigationBar? {
return navigationController?.navigationBar
}
}
// MARK: - LoadMoreConfigurableTableViewContainer // MARK: - LoadMoreConfigurableTableViewContainer
extension PublicTimelineViewController: LoadMoreConfigurableTableViewContainer { extension PublicTimelineViewController: LoadMoreConfigurableTableViewContainer {
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
@ -127,3 +139,66 @@ extension PublicTimelineViewController: LoadMoreConfigurableTableViewContainer {
var loadMoreConfigurableTableView: UITableView { return tableView } var loadMoreConfigurableTableView: UITableView { return tableView }
var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } 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()
}
}
}

View File

@ -14,7 +14,8 @@ extension PublicTimelineViewModel {
func setupDiffableDataSource( func setupDiffableDataSource(
for tableView: UITableView, for tableView: UITableView,
dependency: NeedsDependency, dependency: NeedsDependency,
timelinePostTableViewCellDelegate: TimelinePostTableViewCellDelegate timelinePostTableViewCellDelegate: TimelinePostTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate
) { ) {
let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect() .autoconnect()
@ -26,7 +27,8 @@ extension PublicTimelineViewModel {
dependency: dependency, dependency: dependency,
managedObjectContext: fetchedResultsController.managedObjectContext, managedObjectContext: fetchedResultsController.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher, timestampUpdatePublisher: timestampUpdatePublisher,
timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate
) )
items.value = [] items.value = []
stateMachine.enter(PublicTimelineViewModel.State.Loading.self) stateMachine.enter(PublicTimelineViewModel.State.Loading.self)
@ -42,13 +44,20 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
let indexes = tootIDs.value let indexes = tootIDs.value
let toots = fetchedResultsController.fetchedObjects ?? [] let toots = fetchedResultsController.fetchedObjects ?? []
guard toots.count == indexes.count else { return } guard toots.count == indexes.count else { return }
let items: [Item] = toots let indexTootTuples: [(Int, Toot)] = 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 }
return indexes.firstIndex(of: toot.id).map { index in (index, toot) } return indexes.firstIndex(of: toot.id).map { index in (index, toot) }
} }
.sorted { $0.0 < $1.0 } .sorted { $0.0 < $1.0 }
.map { Item.toot(objectID: $0.1.objectID) } var items = [Item]()
for tuple in indexTootTuples {
items.append(Item.toot(objectID: tuple.1.objectID))
if tootIDsWhichHasGap.contains(tuple.1.id) {
items.append(Item.middleLoader(tootID: tuple.1.id))
}
}
self.items.value = items self.items.value = items
} }
} }

View File

@ -0,0 +1,115 @@
//
// 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
}
viewModel.context.apiService.publicTimeline(
domain: activeMastodonAuthenticationBox.domain,
maxID: upperTimelineTootID
)
.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[...gapIndex])
let downToots = Array(viewModel.tootIDs.value[(gapIndex + 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 { downToots.contains($0.id) }
if intersection.isEmpty {
addedToots.last.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
}
}
}

View File

@ -68,9 +68,21 @@ extension PublicTimelineViewModel.State {
break break
} }
} receiveValue: { response in } receiveValue: { response in
viewModel.isFetchingLatestTimeline.value = false let resposeTootIDs = response.value.compactMap { $0.id }
let tootsIDs = response.value.map { $0.id } var newTootsIDs = resposeTootIDs
viewModel.tootIDs.value = tootsIDs 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.count > 0 {
resposeTootIDs.last.flatMap { viewModel.tootIDsWhichHasGap.append($0) }
}
viewModel.tootIDs.value = newTootsIDs
stateMachine.enter(Idle.self) stateMachine.enter(Idle.self)
} }
.store(in: &viewModel.disposeBag) .store(in: &viewModel.disposeBag)
@ -149,7 +161,6 @@ extension PublicTimelineViewModel.State {
} }
viewModel.tootIDs.value = oldTootsIDs viewModel.tootIDs.value = oldTootsIDs
} }
.store(in: &viewModel.disposeBag) .store(in: &viewModel.disposeBag)
} }

View File

@ -20,9 +20,18 @@ class PublicTimelineViewModel: NSObject {
// input // input
let context: AppContext let context: AppContext
let fetchedResultsController: NSFetchedResultsController<Toot> let fetchedResultsController: NSFetchedResultsController<Toot>
let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false) let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false)
// middle loader
let loadMiddleSateMachineList = CurrentValueSubject<[String: GKStateMachine], Never>([:])
weak var tableView: UITableView? weak var tableView: UITableView?
weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate?
//
var tootIDsWhichHasGap = [String]()
// output // output
var diffableDataSource: UITableViewDiffableDataSource<TimelineSection, Item>? var diffableDataSource: UITableViewDiffableDataSource<TimelineSection, Item>?
@ -68,6 +77,9 @@ 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 }
guard let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return }
guard let tableView = self.tableView else { return }
let oldSnapshot = diffableDataSource.snapshot()
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>()
@ -81,7 +93,21 @@ class PublicTimelineViewModel: NSObject {
break break
} }
} }
diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty)
DispatchQueue.main.async {
guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: snapshot) else {
diffableDataSource.apply(snapshot)
self.isFetchingLatestTimeline.value = false
return
}
diffableDataSource.apply(snapshot, animatingDifferences: false) {
tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false)
tableView.contentOffset.y = tableView.contentOffset.y - difference.offset
self.isFetchingLatestTimeline.value = false
}
}
} }
.store(in: &disposeBag) .store(in: &disposeBag)
@ -103,11 +129,37 @@ 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 {
func loadMore() -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Toot]>, Error> { private struct Difference<T> {
return context.apiService.publicTimeline(domain: "mstdn.jp") let item: T
let sourceIndexPath: IndexPath
let targetIndexPath: IndexPath
let offset: CGFloat
}
private func calculateReloadSnapshotDifference<T: Hashable>(
navigationBar: UINavigationBar,
tableView: UITableView,
oldSnapshot: NSDiffableDataSourceSnapshot<TimelineSection, T>,
newSnapshot: NSDiffableDataSourceSnapshot<TimelineSection, T>
) -> Difference<T>? {
guard oldSnapshot.numberOfItems != 0 else { return nil }
// old snapshot not empty. set source index path to first item if not match
let sourceIndexPath = UIViewController.topVisibleTableViewCellIndexPath(in: tableView, navigationBar: navigationBar) ?? IndexPath(row: 0, section: 0)
guard sourceIndexPath.row < oldSnapshot.itemIdentifiers(inSection: .main).count else { return nil }
let timelineItem = oldSnapshot.itemIdentifiers(inSection: .main)[sourceIndexPath.row]
guard let itemIndex = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: timelineItem) else { return nil }
let targetIndexPath = IndexPath(row: itemIndex, section: 0)
let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: sourceIndexPath, navigationBar: navigationBar)
return Difference(
item: timelineItem,
sourceIndexPath: sourceIndexPath,
targetIndexPath: targetIndexPath,
offset: offset
)
} }
} }

View File

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