feat: [WIP] migrate to Texture (AsyncDisplayKit) for better performance

This commit is contained in:
CMK 2021-06-19 18:33:29 +08:00
parent e23b6cb641
commit 1a3135b998
21 changed files with 933 additions and 255 deletions

View File

@ -407,6 +407,15 @@
DBA9443E265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */; };
DBA94440265D137600C537E1 /* Mastodon+Entity+Field.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA9443F265D137600C537E1 /* Mastodon+Entity+Field.swift */; };
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; };
DBAC6483267D0B21007FE9FD /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = DBAC6482267D0B21007FE9FD /* DifferenceKit */; };
DBAC6485267D0F9E007FE9FD /* StatusNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6484267D0F9E007FE9FD /* StatusNode.swift */; };
DBAC6488267D388B007FE9FD /* ASTableNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6487267D388B007FE9FD /* ASTableNode.swift */; };
DBAC648A267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6489267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift */; };
DBAC648F267DC84D007FE9FD /* TableNodeDiffableDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC648E267DC84D007FE9FD /* TableNodeDiffableDataSource.swift */; };
DBAC6497267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6496267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift */; };
DBAC6499267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC6498267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift */; };
DBAC649B267DF8C8007FE9FD /* ActivityIndicatorNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAC649A267DF8C8007FE9FD /* ActivityIndicatorNode.swift */; };
DBAC649E267DFE43007FE9FD /* DiffableDataSources in Frameworks */ = {isa = PBXBuildFile; productRef = DBAC649D267DFE43007FE9FD /* DiffableDataSources */; };
DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F672615DD60004B8251 /* UserProvider.swift */; };
DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */; };
DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */; };
@ -981,6 +990,13 @@
DBA9443D265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldCollectionViewCell.swift; sourceTree = "<group>"; };
DBA9443F265D137600C537E1 /* Mastodon+Entity+Field.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Field.swift"; sourceTree = "<group>"; };
DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = "<group>"; };
DBAC6484267D0F9E007FE9FD /* StatusNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusNode.swift; sourceTree = "<group>"; };
DBAC6487267D388B007FE9FD /* ASTableNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASTableNode.swift; sourceTree = "<group>"; };
DBAC6489267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSDiffableDataSourceSnapshot.swift; sourceTree = "<group>"; };
DBAC648E267DC84D007FE9FD /* TableNodeDiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableNodeDiffableDataSource.swift; sourceTree = "<group>"; };
DBAC6496267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderNode.swift; sourceTree = "<group>"; };
DBAC6498267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderNode.swift; sourceTree = "<group>"; };
DBAC649A267DF8C8007FE9FD /* ActivityIndicatorNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorNode.swift; sourceTree = "<group>"; };
DBAE3F672615DD60004B8251 /* UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProvider.swift; sourceTree = "<group>"; };
DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = "<group>"; };
DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProviderFacade.swift; sourceTree = "<group>"; };
@ -1065,8 +1081,10 @@
DB6804862637CD4C00430867 /* AppShared.framework in Frameworks */,
DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */,
DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */,
DBAC6483267D0B21007FE9FD /* DifferenceKit in Frameworks */,
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */,
DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */,
DBAC649E267DFE43007FE9FD /* DiffableDataSources in Frameworks */,
2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */,
87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */,
DB6804C82637CE2F00430867 /* AppShared.framework in Frameworks */,
@ -1337,6 +1355,7 @@
DB51D171262832380062B7A1 /* BlurHashEncode.swift */,
DB6180EC26391C6C0018D199 /* TransitioningMath.swift */,
DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */,
DBAC649A267DF8C8007FE9FD /* ActivityIndicatorNode.swift */,
);
path = Vender;
sourceTree = "<group>";
@ -1414,6 +1433,7 @@
2D76319D25C151F600929FB9 /* Section */,
2D7631B125C159E700929FB9 /* Item */,
DBCBED2226132E1D00B49291 /* FetchedResultsController */,
DBAC6490267DC84F007FE9FD /* DataSource */,
);
path = Diffiable;
sourceTree = "<group>";
@ -1464,6 +1484,7 @@
DB87D45C2609DE6600D12C0D /* TextField */,
DB1D187125EF5BBD003F1F23 /* TableView */,
2D7631A625C1533800929FB9 /* TableviewCell */,
DBAC6486267D0FAC007FE9FD /* Node */,
);
path = View;
sourceTree = "<group>";
@ -2167,6 +2188,7 @@
DBCC3B35261440BA0045B23D /* UINavigationController.swift */,
DB6D1B23263684C600ACB481 /* UserDefaults.swift */,
DB97131E2666078B00BD1E90 /* Date.swift */,
DBAC6489267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift */,
);
path = Extension;
sourceTree = "<group>";
@ -2328,6 +2350,25 @@
path = View;
sourceTree = "<group>";
};
DBAC6486267D0FAC007FE9FD /* Node */ = {
isa = PBXGroup;
children = (
DBAC6484267D0F9E007FE9FD /* StatusNode.swift */,
DBAC6496267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift */,
DBAC6498267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift */,
);
path = Node;
sourceTree = "<group>";
};
DBAC6490267DC84F007FE9FD /* DataSource */ = {
isa = PBXGroup;
children = (
DBAC6487267D388B007FE9FD /* ASTableNode.swift */,
DBAC648E267DC84D007FE9FD /* TableNodeDiffableDataSource.swift */,
);
path = DataSource;
sourceTree = "<group>";
};
DBAE3F742615DD63004B8251 /* UserProvider */ = {
isa = PBXGroup;
children = (
@ -2510,6 +2551,8 @@
DBB525072611EAC0002F1F29 /* Tabman */,
DB6F5E31264E7410009108F4 /* TwitterTextEditor */,
DBAEDE5E267A0B1500D25FF5 /* Nuke */,
DBAC6482267D0B21007FE9FD /* DifferenceKit */,
DBAC649D267DFE43007FE9FD /* DiffableDataSources */,
);
productName = Mastodon;
productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */;
@ -2698,6 +2741,8 @@
DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */,
DB6F5E30264E7410009108F4 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */,
DBAEDE5D267A0B1500D25FF5 /* XCRemoteSwiftPackageReference "Nuke" */,
DBAC6481267D0B21007FE9FD /* XCRemoteSwiftPackageReference "DifferenceKit" */,
DBAC649C267DFE43007FE9FD /* XCRemoteSwiftPackageReference "DiffableDataSources" */,
);
productRefGroup = DB427DD325BAA00100D1B89D /* Products */;
projectDirPath = "";
@ -2992,9 +3037,11 @@
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */,
DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */,
DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */,
DBAC648F267DC84D007FE9FD /* TableNodeDiffableDataSource.swift in Sources */,
2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */,
0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */,
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */,
DBAC649B267DF8C8007FE9FD /* ActivityIndicatorNode.swift in Sources */,
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */,
DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */,
@ -3036,6 +3083,7 @@
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */,
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */,
DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */,
DBAC648A267DC355007FE9FD /* NSDiffableDataSourceSnapshot.swift in Sources */,
DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */,
DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */,
2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */,
@ -3056,7 +3104,9 @@
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */,
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */,
2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */,
DBAC6497267DECCB007FE9FD /* TimelineMiddleLoaderNode.swift in Sources */,
DB97131F2666078B00BD1E90 /* Date.swift in Sources */,
DBAC6485267D0F9E007FE9FD /* StatusNode.swift in Sources */,
DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
DB6180E926391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift in Sources */,
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */,
@ -3129,6 +3179,7 @@
5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */,
2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */,
DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */,
DBAC6488267D388B007FE9FD /* ASTableNode.swift in Sources */,
DB6D9F76263587C7008423CD /* SettingFetchedResultController.swift in Sources */,
DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */,
5D0393902612D259007FE196 /* WebViewController.swift in Sources */,
@ -3262,6 +3313,7 @@
5DFC35DF262068D20045711D /* SearchViewController+Follow.swift in Sources */,
DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */,
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
DBAC6499267DF2C4007FE9FD /* TimelineBottomLoaderNode.swift in Sources */,
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
DBBF1DBF2652401B00E5B703 /* AutoCompleteViewModel.swift in Sources */,
DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */,
@ -4118,6 +4170,22 @@
minimumVersion = 1.4.1;
};
};
DBAC6481267D0B21007FE9FD /* XCRemoteSwiftPackageReference "DifferenceKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/ra1028/DifferenceKit.git";
requirement = {
kind = exactVersion;
version = 1.2.0;
};
};
DBAC649C267DFE43007FE9FD /* XCRemoteSwiftPackageReference "DiffableDataSources" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/MainasuK/DiffableDataSources.git";
requirement = {
branch = "feature/async-display-table";
kind = branch;
};
};
DBAEDE5D267A0B1500D25FF5 /* XCRemoteSwiftPackageReference "Nuke" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kean/Nuke.git";
@ -4201,6 +4269,16 @@
package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */;
productName = "UITextView+Placeholder";
};
DBAC6482267D0B21007FE9FD /* DifferenceKit */ = {
isa = XCSwiftPackageProductDependency;
package = DBAC6481267D0B21007FE9FD /* XCRemoteSwiftPackageReference "DifferenceKit" */;
productName = DifferenceKit;
};
DBAC649D267DFE43007FE9FD /* DiffableDataSources */ = {
isa = XCSwiftPackageProductDependency;
package = DBAC649C267DFE43007FE9FD /* XCRemoteSwiftPackageReference "DiffableDataSources" */;
productName = DiffableDataSources;
};
DBAEDE5E267A0B1500D25FF5 /* Nuke */ = {
isa = XCSwiftPackageProductDependency;
package = DBAEDE5D267A0B1500D25FF5 /* XCRemoteSwiftPackageReference "Nuke" */;

View File

@ -7,12 +7,12 @@
<key>AppShared.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>14</integer>
<integer>19</integer>
</dict>
<key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>17</integer>
<integer>18</integer>
</dict>
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict>
@ -27,12 +27,12 @@
<key>Mastodon.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
<integer>17</integer>
</dict>
<key>NotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>16</integer>
<integer>20</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@ -46,6 +46,24 @@
"version": "0.1.1"
}
},
{
"package": "DiffableDataSources",
"repositoryURL": "https://github.com/MainasuK/DiffableDataSources.git",
"state": {
"branch": "feature/async-display-table",
"revision": "73393a97690959d24387c95594c045c62d9c47cf",
"version": null
}
},
{
"package": "DifferenceKit",
"repositoryURL": "https://github.com/ra1028/DifferenceKit.git",
"state": {
"branch": null,
"revision": "62745d7780deef4a023a792a1f8f763ec7bf9705",
"version": "1.2.0"
}
},
{
"package": "KeychainAccess",
"repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess.git",

View File

@ -0,0 +1,81 @@
//
// ASTableNode.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-6-19.
//
import UIKit
import AsyncDisplayKit
import DifferenceKit
import DiffableDataSources
extension ASTableNode: ReloadableTableView {
public func reload<C>(
using stagedChangeset: StagedChangeset<C>,
deleteSectionsAnimation: @autoclosure () -> UITableView.RowAnimation,
insertSectionsAnimation: @autoclosure () -> UITableView.RowAnimation,
reloadSectionsAnimation: @autoclosure () -> UITableView.RowAnimation,
deleteRowsAnimation: @autoclosure () -> UITableView.RowAnimation,
insertRowsAnimation: @autoclosure () -> UITableView.RowAnimation,
reloadRowsAnimation: @autoclosure () -> UITableView.RowAnimation,
interrupt: ((Changeset<C>) -> Bool)? = nil,
setData: (C) -> Void
) {
if case .none = view.window, let data = stagedChangeset.last?.data {
setData(data)
return reloadData()
}
for changeset in stagedChangeset {
if let interrupt = interrupt, interrupt(changeset), let data = stagedChangeset.last?.data {
setData(data)
return reloadData()
}
func updates() {
setData(changeset.data)
if !changeset.sectionDeleted.isEmpty {
deleteSections(IndexSet(changeset.sectionDeleted), with: deleteSectionsAnimation())
}
if !changeset.sectionInserted.isEmpty {
insertSections(IndexSet(changeset.sectionInserted), with: insertSectionsAnimation())
}
if !changeset.sectionUpdated.isEmpty {
reloadSections(IndexSet(changeset.sectionUpdated), with: reloadSectionsAnimation())
}
for (source, target) in changeset.sectionMoved {
moveSection(source, toSection: target)
}
if !changeset.elementDeleted.isEmpty {
deleteRows(at: changeset.elementDeleted.map { IndexPath(row: $0.element, section: $0.section) }, with: deleteRowsAnimation())
}
if !changeset.elementInserted.isEmpty {
insertRows(at: changeset.elementInserted.map { IndexPath(row: $0.element, section: $0.section) }, with: insertRowsAnimation())
}
if !changeset.elementUpdated.isEmpty {
reloadRows(at: changeset.elementUpdated.map { IndexPath(row: $0.element, section: $0.section) }, with: reloadRowsAnimation())
}
for (source, target) in changeset.elementMoved {
moveRow(at: IndexPath(row: source.element, section: source.section), to: IndexPath(row: target.element, section: target.section))
}
}
if isNodeLoaded {
view.beginUpdates()
updates()
view.endUpdates(animated: false, completion: nil)
} else {
updates()
}
}
}
}

View File

@ -0,0 +1,111 @@
//
// TableNodeDiffableDataSource.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-6-19.
//
import UIKit
import AsyncDisplayKit
import DiffableDataSources
open class TableNodeDiffableDataSource<SectionIdentifierType: Hashable, ItemIdentifierType: Hashable>: NSObject, ASTableDataSource {
/// The type of closure providing the cell.
public typealias CellProvider = (ASTableNode, IndexPath, ItemIdentifierType) -> ASCellNodeBlock?
/// The default animation to updating the views.
public var defaultRowAnimation: UITableView.RowAnimation = .automatic
private weak var tableNode: ASTableNode?
private let cellProvider: CellProvider
private let core = DiffableDataSourceCore<SectionIdentifierType, ItemIdentifierType>()
/// Creates a new data source.
///
/// - Parameters:
/// - tableView: A table view instance to be managed.
/// - cellProvider: A closure to dequeue the cell for rows.
public init(tableNode: ASTableNode, cellProvider: @escaping CellProvider) {
self.tableNode = tableNode
self.cellProvider = cellProvider
super.init()
tableNode.dataSource = self
}
/// Applies given snapshot to perform automatic diffing update.
///
/// - Parameters:
/// - snapshot: A snapshot object to be applied to data model.
/// - animatingDifferences: A Boolean value indicating whether to update with
/// diffing animation.
/// - completion: An optional completion block which is called when the complete
/// performing updates.
public func apply(_ snapshot: DiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType>, animatingDifferences: Bool = true, completion: (() -> Void)? = nil) {
core.apply(snapshot, view: tableNode, animatingDifferences: animatingDifferences, completion: completion)
}
/// Returns a new snapshot object of current state.
///
/// - Returns: A new snapshot object of current state.
public func snapshot() -> DiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType> {
return core.snapshot()
}
/// Returns an item identifier for given index path.
///
/// - Parameters:
/// - indexPath: An index path for the item identifier.
///
/// - Returns: An item identifier for given index path.
public func itemIdentifier(for indexPath: IndexPath) -> ItemIdentifierType? {
return core.itemIdentifier(for: indexPath)
}
/// Returns an index path for given item identifier.
///
/// - Parameters:
/// - itemIdentifier: An identifier of item.
///
/// - Returns: An index path for given item identifier.
public func indexPath(for itemIdentifier: ItemIdentifierType) -> IndexPath? {
return core.indexPath(for: itemIdentifier)
}
/// Returns the number of sections in the data source.
///
/// - Parameters:
/// - tableNode: A table node instance managed by `self`.
///
/// - Returns: The number of sections in the data source.
public func numberOfSections(in tableNode: ASTableNode) -> Int {
return core.numberOfSections()
}
/// Returns the number of items in the specified section.
///
/// - Parameters:
/// - tableNode: A table node instance managed by `self`.
/// - section: An index of section.
///
/// - Returns: The number of items in the specified section.
public func tableNode(_ tableNode: ASTableNode, numberOfRowsInSection section: Int) -> Int {
return core.numberOfItems(inSection: section)
}
/// Returns a cell for row at specified index path.
///
/// - Parameters:
/// - tableView: A table view instance managed by `self`.
/// - indexPath: An index path for cell.
///
/// - Returns: A cell for row at specified index path.
open func tableNode(_ tableNode: ASTableNode, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock {
let itemIdentifier = core.unsafeItemIdentifier(for: indexPath)
guard let block = cellProvider(tableNode, indexPath, itemIdentifier) else {
fatalError("UITableView dataSource returned a nil cell for row at index path: \(indexPath), tableNode: \(tableNode), itemIdentifier: \(itemIdentifier)")
}
return block
}
}

View File

@ -10,6 +10,7 @@ import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import DifferenceKit
/// Note: update Equatable when change case
enum Item {
@ -158,3 +159,5 @@ extension Item: Hashable {
}
}
}
extension Item: Differentiable { }

View File

@ -11,6 +11,7 @@ import CoreDataStack
import os.log
import UIKit
import AVKit
import AsyncDisplayKit
import Nuke
protocol StatusCell: DisposeBagCollectable {
@ -23,6 +24,33 @@ enum StatusSection: Equatable, Hashable {
}
extension StatusSection {
static func tableNodeDiffableDataSource(
tableNode: ASTableNode,
managedObjectContext: NSManagedObjectContext
) -> TableNodeDiffableDataSource<StatusSection, Item> {
TableNodeDiffableDataSource(tableNode: tableNode) { tableNode, indexPath, item in
switch item {
case .homeTimelineIndex(let objectID, let attribute):
guard let homeTimelineIndex = try? managedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else {
return { ASCellNode() }
}
let status = homeTimelineIndex.status
return { () -> ASCellNode in
let cellNode = StatusNode(status: status)
return cellNode
}
case .homeMiddleLoader:
return { TimelineMiddleLoaderNode() }
case .bottomLoader:
return { TimelineBottomLoaderNode() }
default:
return { ASCellNode() }
}
}
}
static func tableViewDiffableDataSource(
for tableView: UITableView,
dependency: NeedsDependency,

View File

@ -0,0 +1,24 @@
//
// NSDiffableDataSourceSnapshot.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-6-19.
//
import UIKit
//extension NSDiffableDataSourceSnapshot {
// func itemIdentifier(for indexPath: IndexPath) -> ItemIdentifierType? {
// guard 0..<numberOfSections ~= indexPath.section else {
// return nil
// }
//
// let items = itemIdentifiers(inSection: sectionIdentifiers[indexPath.section])
//
// guard 0..<items.endIndex ~= indexPath.item else {
// return nil
// }
//
// return items[indexPath.item]
// }
//}

View File

@ -109,7 +109,7 @@ extension HomeTimelineViewController {
image: UIImage(systemName: "minus.circle"),
identifier: nil,
options: [],
children: [50, 100, 150, 200, 250, 300].map { count in
children: [10, 50, 100, 150, 200, 250, 300].map { count in
UIAction(title: "Drop Recent \(count) Statuses", image: nil, attributes: [], handler: { [weak self] action in
guard let self = self else { return }
self.dropRecentStatusAction(action, count: count)
@ -269,7 +269,7 @@ extension HomeTimelineViewController {
@objc private func dropRecentStatusAction(_ sender: UIAction, count: Int) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let snapshotTransitioning = diffableDataSource.snapshot()
let droppingObjectIDs = snapshotTransitioning.itemIdentifiers.prefix(count).compactMap { item -> NSManagedObjectID? in
switch item {
case .homeTimelineIndex(let objectID, _): return objectID
@ -354,5 +354,30 @@ extension HomeTimelineViewController {
transition: .modal(animated: true, completion: nil)
)
}
@objc func signOutAction(_ sender: UIAction) {
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
context.authenticationService.signOutMastodonUser(
domain: activeMastodonAuthenticationBox.domain,
userID: activeMastodonAuthenticationBox.userID
)
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
guard let self = self else { return }
switch result {
case .failure(let error):
assertionFailure(error.localizedDescription)
case .success(let isSignOut):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign out %s", ((#file as NSString).lastPathComponent), #line, #function, isSignOut ? "success" : "fail")
guard isSignOut else { return }
self.coordinator.setup()
self.coordinator.setupOnboardingIfNeeds(animated: true)
}
}
.store(in: &disposeBag)
}
}
#endif

View File

@ -53,7 +53,7 @@ extension HomeTimelineViewController: StatusProvider {
}
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? {
return viewModel.diffableDataSource
return nil
}
func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? {

View File

@ -14,12 +14,13 @@ import CoreDataStack
import GameplayKit
import MastodonSDK
import AlamofireImage
import AsyncDisplayKit
#if DEBUG
import GDPerformanceView_Swift
#endif
final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
final class HomeTimelineViewController: ASDKViewController<ASTableNode>, NeedsDependency, MediaPreviewableViewController {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
@ -53,17 +54,18 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media
barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate)
return barButtonItem
}()
let tableView: UITableView = {
let tableView = ControlContainableTableView()
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.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
tableView.backgroundColor = .clear
return tableView
}()
var tableView: UITableView { node.view }
//let tableView: UITableView = {
// let tableView = ControlContainableTableView()
// tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.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
// tableView.backgroundColor = .clear
// return tableView
//}()
let publishProgressView: UIProgressView = {
let progressView = UIProgressView(progressViewStyle: .bar)
@ -72,7 +74,16 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media
}()
let refreshControl = UIRefreshControl()
override init() {
super.init(node: ASTableNode())
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
}
@ -83,13 +94,15 @@ extension HomeTimelineViewController {
override func viewDidLoad() {
super.viewDidLoad()
node.allowsSelection = true
title = L10n.Scene.HomeTimeline.title
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
navigationItem.leftBarButtonItem = settingBarButtonItem
navigationItem.titleView = titleView
titleView.delegate = self
viewModel.homeTimelineNavigationBarTitleViewModel.state
.removeDuplicates()
.receive(on: DispatchQueue.main)
@ -98,52 +111,56 @@ extension HomeTimelineViewController {
self.titleView.configure(state: state)
}
.store(in: &disposeBag)
#if DEBUG
// long press to trigger debug menu
settingBarButtonItem.menu = debugMenu
PerformanceMonitor.shared().delegate = self
#else
settingBarButtonItem.target = self
settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:))
#endif
navigationItem.rightBarButtonItem = composeBarButtonItem
composeBarButtonItem.target = self
composeBarButtonItem.action = #selector(HomeTimelineViewController.composeBarButtonItemPressed(_:))
tableView.refreshControl = refreshControl
node.view.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(HomeTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged)
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
publishProgressView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(publishProgressView)
NSLayoutConstraint.activate([
publishProgressView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
publishProgressView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
publishProgressView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
viewModel.tableView = tableView
//
// tableView.translatesAutoresizingMaskIntoConstraints = false
// view.addSubview(tableView)
// NSLayoutConstraint.activate([
// tableView.topAnchor.constraint(equalTo: view.topAnchor),
// tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
// tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
// tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
// ])
//
// publishProgressView.translatesAutoresizingMaskIntoConstraints = false
// view.addSubview(publishProgressView)
// NSLayoutConstraint.activate([
// publishProgressView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
// publishProgressView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
// publishProgressView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
// ])
//
// viewModel.tableView = tableView
viewModel.tableNode = node
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
tableView.delegate = self
tableView.prefetchDataSource = self
node.delegate = self
viewModel.setupDiffableDataSource(
for: tableView,
tableNode: node,
dependency: self,
statusTableViewCellDelegate: self,
timelineMiddleLoaderTableViewCellDelegate: self
)
// tableView.delegate = self
// tableView.prefetchDataSource = self
// bind refresh control
viewModel.isFetchingLatestTimeline
.receive(on: DispatchQueue.main)
@ -157,88 +174,88 @@ extension HomeTimelineViewController {
}
}
.store(in: &disposeBag)
viewModel.homeTimelineNavigationBarTitleViewModel.publishingProgress
.receive(on: DispatchQueue.main)
.sink { [weak self] progress in
guard let self = self else { return }
guard progress > 0 else {
let dismissAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut)
dismissAnimator.addAnimations {
self.publishProgressView.alpha = 0
}
dismissAnimator.addCompletion { _ in
self.publishProgressView.setProgress(0, animated: false)
}
dismissAnimator.startAnimation()
return
}
if self.publishProgressView.alpha == 0 {
let progressAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeOut)
progressAnimator.addAnimations {
self.publishProgressView.alpha = 1
}
progressAnimator.startAnimation()
}
self.publishProgressView.setProgress(progress, animated: true)
}
.store(in: &disposeBag)
viewModel.timelineIsEmpty
.receive(on: DispatchQueue.main)
.sink { [weak self] isEmpty in
if isEmpty {
self?.showEmptyView()
} else {
self?.emptyView.removeFromSuperview()
}
}
.store(in: &disposeBag)
// viewModel.homeTimelineNavigationBarTitleViewModel.publishingProgress
// .receive(on: DispatchQueue.main)
// .sink { [weak self] progress in
// guard let self = self else { return }
// guard progress > 0 else {
// let dismissAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut)
// dismissAnimator.addAnimations {
// self.publishProgressView.alpha = 0
// }
// dismissAnimator.addCompletion { _ in
// self.publishProgressView.setProgress(0, animated: false)
// }
// dismissAnimator.startAnimation()
// return
// }
// if self.publishProgressView.alpha == 0 {
// let progressAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeOut)
// progressAnimator.addAnimations {
// self.publishProgressView.alpha = 1
// }
// progressAnimator.startAnimation()
// }
//
// self.publishProgressView.setProgress(progress, animated: true)
// }
// .store(in: &disposeBag)
//
// viewModel.timelineIsEmpty
// .receive(on: DispatchQueue.main)
// .sink { [weak self] isEmpty in
// if isEmpty {
// self?.showEmptyView()
// } else {
// self?.emptyView.removeFromSuperview()
// }
// }
// .store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
aspectViewWillAppear(animated)
// needs trigger manually after onboarding dismiss
setNeedsStatusBarAppearanceUpdate()
if (viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty {
viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self)
}
// aspectViewWillAppear(animated)
//
// // needs trigger manually after onboarding dismiss
// setNeedsStatusBarAppearanceUpdate()
//
// if (viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty {
// viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self)
// }
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.viewDidAppear.send()
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if (self.viewModel.fetchedResultsController.fetchedObjects ?? []).count == 0 {
self.viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self)
}
}
// viewModel.viewDidAppear.send()
//
// DispatchQueue.main.async { [weak self] in
// guard let self = self else { return }
// if (self.viewModel.fetchedResultsController.fetchedObjects ?? []).count == 0 {
// self.viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self)
// }
// }
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
aspectViewDidDisappear(animated)
// aspectViewDidDisappear(animated)
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate { _ in
// do nothing
} completion: { _ in
// fix AutoLayout cell height not update after rotate issue
self.viewModel.cellFrameCache.removeAllObjects()
self.tableView.reloadData()
}
// coordinator.animate { _ in
// // do nothing
// } completion: { _ in
// // fix AutoLayout cell height not update after rotate issue
// self.viewModel.cellFrameCache.removeAllObjects()
// self.tableView.reloadData()
// }
}
}
@ -315,100 +332,75 @@ extension HomeTimelineViewController {
return
}
}
@objc func signOutAction(_ sender: UIAction) {
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
context.authenticationService.signOutMastodonUser(
domain: activeMastodonAuthenticationBox.domain,
userID: activeMastodonAuthenticationBox.userID
)
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
guard let self = self else { return }
switch result {
case .failure(let error):
assertionFailure(error.localizedDescription)
case .success(let isSignOut):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign out %s", ((#file as NSString).lastPathComponent), #line, #function, isSignOut ? "success" : "fail")
guard isSignOut else { return }
self.coordinator.setup()
self.coordinator.setupOnboardingIfNeeds(animated: true)
}
}
.store(in: &disposeBag)
}
}
// MARK: - StatusTableViewControllerAspect
extension HomeTimelineViewController: StatusTableViewControllerAspect { }
//extension HomeTimelineViewController: StatusTableViewControllerAspect { }
extension HomeTimelineViewController: TableViewCellHeightCacheableContainer {
var cellFrameCache: NSCache<NSNumber, NSValue> { return viewModel.cellFrameCache }
}
//extension HomeTimelineViewController: TableViewCellHeightCacheableContainer {
// var cellFrameCache: NSCache<NSNumber, NSValue> { return viewModel.cellFrameCache }
//}
// MARK: - UIScrollViewDelegate
extension HomeTimelineViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
aspectScrollViewDidScroll(scrollView)
//aspectScrollViewDidScroll(scrollView)
viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView)
}
}
extension HomeTimelineViewController: LoadMoreConfigurableTableViewContainer {
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
typealias LoadingState = HomeTimelineViewModel.LoadOldestState.Loading
var loadMoreConfigurableTableView: UITableView { return tableView }
var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadoldestStateMachine }
}
//extension HomeTimelineViewController: LoadMoreConfigurableTableViewContainer {
// typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
// typealias LoadingState = HomeTimelineViewModel.LoadOldestState.Loading
// var loadMoreConfigurableTableView: UITableView { return tableView }
// var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadoldestStateMachine }
//}
// MARK: - UITableViewDelegate
extension HomeTimelineViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath)
}
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
aspectTableView(tableView, didSelectRowAt: indexPath)
}
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
}
func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
}
func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
}
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
}
}
//extension HomeTimelineViewController: UITableViewDelegate {
//
// func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
// aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
// }
//
// func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath)
// }
//
// func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
// }
//
// func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
// aspectTableView(tableView, didSelectRowAt: indexPath)
// }
//
// func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
// return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
// }
//
// func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
// return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
// }
//
// func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
// return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
// }
//
// func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
// aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
// }
//
//}
// MARK: - UITableViewDataSourcePrefetching
extension HomeTimelineViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
aspectTableView(tableView, prefetchRowsAt: indexPaths)
}
}
//extension HomeTimelineViewController: UITableViewDataSourcePrefetching {
// func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
// aspectTableView(tableView, prefetchRowsAt: indexPaths)
// }
//}
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
extension HomeTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate {
@ -482,9 +474,9 @@ extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate
// MARK: - ScrollViewContainer
extension HomeTimelineViewController: ScrollViewContainer {
var scrollView: UIScrollView { return tableView }
func scrollToTop(animated: Bool) {
if scrollView.contentOffset.y < scrollView.frame.height,
viewModel.loadLatestStateMachine.canEnterState(HomeTimelineViewModel.LoadLatestState.Loading.self),
@ -499,10 +491,10 @@ extension HomeTimelineViewController: ScrollViewContainer {
} else {
let indexPath = IndexPath(row: 0, section: 0)
guard viewModel.diffableDataSource?.itemIdentifier(for: indexPath) != nil else { return }
tableView.scrollToRow(at: indexPath, at: .top, animated: true)
node.scrollToRow(at: indexPath, at: .top, animated: true)
}
}
}
// MARK: - AVPlayerViewControllerDelegate
@ -532,7 +524,7 @@ extension HomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate
guard let diffableDataSource = viewModel.diffableDataSource else { return }
let indexPath = IndexPath(row: 0, section: 0)
guard diffableDataSource.itemIdentifier(for: indexPath) != nil else { return }
tableView.scrollToRow(at: indexPath, at: .top, animated: true)
node.scrollToRow(at: indexPath, at: .top, animated: true)
case .offlineButton:
// TODO: retry
break
@ -568,3 +560,20 @@ extension HomeTimelineViewController: PerformanceMonitorDelegate {
}
}
#endif
// MARK: - ASTableDelegate
extension HomeTimelineViewController: ASTableDelegate {
func shouldBatchFetch(for tableNode: ASTableNode) -> Bool {
switch viewModel.loadLatestStateMachine.currentState {
case is HomeTimelineViewModel.LoadOldestState.NoMore:
return false
default:
return true
}
}
func tableNode(_ tableNode: ASTableNode, willBeginBatchFetchWith context: ASBatchContext) {
viewModel.loadoldestStateMachine.enter(HomeTimelineViewModel.LoadOldestState.Loading.self)
context.completeBatchFetching(true)
}
}

View File

@ -9,35 +9,30 @@ import os.log
import UIKit
import CoreData
import CoreDataStack
import AsyncDisplayKit
import DifferenceKit
import DiffableDataSources
extension HomeTimelineViewModel {
func setupDiffableDataSource(
for tableView: UITableView,
tableNode: ASTableNode,
dependency: NeedsDependency,
statusTableViewCellDelegate: StatusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate
) {
let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.share()
.eraseToAnyPublisher()
diffableDataSource = StatusSection.tableViewDiffableDataSource(
for: tableView,
dependency: dependency,
managedObjectContext: fetchedResultsController.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate,
threadReplyLoaderTableViewCellDelegate: nil
tableNode.automaticallyAdjustsContentOffset = true
diffableDataSource = StatusSection.tableNodeDiffableDataSource(
tableNode: tableNode,
managedObjectContext: fetchedResultsController.managedObjectContext
)
// var snapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
// snapshot.appendSections([.main])
// diffableDataSource?.apply(snapshot)
var snapshot = DiffableDataSourceSnapshot<StatusSection, Item>()
snapshot.appendSections([.main])
diffableDataSource?.apply(snapshot)
}
}
// MARK: - NSFetchedResultsControllerDelegate
@ -49,21 +44,18 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate {
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard let tableView = self.tableView else { return }
guard let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
let oldSnapshot = diffableDataSource.snapshot()
let predicate = fetchedResultsController.fetchRequest.predicate
let parentManagedObjectContext = fetchedResultsController.managedObjectContext
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
managedObjectContext.parent = parentManagedObjectContext
managedObjectContext.perform {
var shouldAddBottomLoader = false
let timelineIndexes: [HomeTimelineIndex] = {
let request = HomeTimelineIndex.sortedFetchRequest
request.returnsObjectsAsFaults = false
@ -75,25 +67,25 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate {
return []
}
}()
// that's will be the most fastest fetch because of upstream just update and no modify needs consider
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
for item in oldSnapshot.itemIdentifiers {
guard case let .homeTimelineIndex(objectID, attribute) = item else { continue }
oldSnapshotAttributeDict[objectID] = attribute
}
var newTimelineItems: [Item] = []
for (i, timelineIndex) in timelineIndexes.enumerated() {
let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute()
attribute.isSeparatorLineHidden = false
// append new item into snapshot
newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute))
let isLast = i == timelineIndexes.count - 1
switch (isLast, timelineIndex.hasMore) {
case (false, true):
@ -105,30 +97,22 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate {
break
}
} // end for
var newSnapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
var newSnapshot = DiffableDataSourceSnapshot<StatusSection, Item>()
newSnapshot.appendSections([.main])
newSnapshot.appendItems(newTimelineItems, toSection: .main)
let endSnapshot = CACurrentMediaTime()
DispatchQueue.main.async {
if shouldAddBottomLoader, !(self.loadoldestStateMachine.currentState is LoadOldestState.NoMore) {
newSnapshot.appendItems([.bottomLoader], toSection: .main)
}
guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else {
diffableDataSource.apply(newSnapshot)
self.isFetchingLatestTimeline.value = false
return
}
diffableDataSource.apply(newSnapshot, animatingDifferences: false) {
tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false)
tableView.contentOffset.y = tableView.contentOffset.y - difference.offset
if shouldAddBottomLoader, !(self.loadoldestStateMachine.currentState is LoadOldestState.NoMore) {
newSnapshot.appendItems([.bottomLoader], toSection: .main)
}
diffableDataSource.apply(newSnapshot, animatingDifferences: false) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.isFetchingLatestTimeline.value = false
}
let end = CACurrentMediaTime()
os_log("%{public}s[%{public}ld], %{public}s: calculate home timeline layout cost %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - endSnapshot)
}
@ -145,8 +129,8 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate {
private func calculateReloadSnapshotDifference<T: Hashable>(
navigationBar: UINavigationBar,
tableView: UITableView,
oldSnapshot: NSDiffableDataSourceSnapshot<StatusSection, T>,
newSnapshot: NSDiffableDataSourceSnapshot<StatusSection, T>
oldSnapshot: DiffableDataSourceSnapshot<StatusSection, T>,
newSnapshot: DiffableDataSourceSnapshot<StatusSection, T>
) -> Difference<T>? {
guard oldSnapshot.numberOfItems != 0 else { return nil }

View File

@ -16,6 +16,7 @@ import GameplayKit
import AlamofireImage
import DateToolsSwift
import ActiveLabel
import AsyncDisplayKit
final class HomeTimelineViewModel: NSObject {
@ -29,15 +30,18 @@ final class HomeTimelineViewModel: NSObject {
let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false)
let viewDidAppear = PassthroughSubject<Void, Never>()
let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel
weak var tableNode: ASTableNode?
weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate?
weak var tableView: UITableView?
//weak var tableView: UITableView?
weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
let timelineIsEmpty = CurrentValueSubject<Bool, Never>(false)
let homeTimelineNeedRefresh = PassthroughSubject<Void, Never>()
// output
var diffableDataSource: TableNodeDiffableDataSource<StatusSection, Item>?
// top loader
private(set) lazy var loadLatestStateMachine: GKStateMachine = {
// exclude timeline middle fetcher state
@ -67,7 +71,7 @@ final class HomeTimelineViewModel: NSObject {
lazy var loadOldestStateMachinePublisher = CurrentValueSubject<LoadOldestState?, Never>(nil)
// middle loader
let loadMiddleSateMachineList = CurrentValueSubject<[NSManagedObjectID: GKStateMachine], Never>([:]) // TimelineIndex.objectID : middle loading state machine
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
// var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
var cellFrameCache = NSCache<NSNumber, NSValue>()
@ -100,12 +104,7 @@ final class HomeTimelineViewModel: NSObject {
guard let self = self else { return }
self.fetchedResultsController.fetchRequest.predicate = predicate
do {
self.diffableDataSource?.defaultRowAnimation = .fade
try self.fetchedResultsController.performFetch()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
guard let self = self else { return }
self.diffableDataSource?.defaultRowAnimation = .automatic
}
} catch {
assertionFailure(error.localizedDescription)
}

View File

@ -0,0 +1,109 @@
//
// StatusNNode.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-6-19.
//
import UIKit
import Combine
import AsyncDisplayKit
import CoreDataStack
final class StatusNode: ASCellNode {
var disposeBag = Set<AnyCancellable>()
static let avatarImageSize = CGSize(width: 42, height: 42)
static let avatarImageCornerRadius: CGFloat = 4
let avatarImageNode: ASNetworkImageNode = {
let node = ASNetworkImageNode()
node.contentMode = .scaleAspectFill
node.defaultImage = UIImage.placeholder(color: .systemFill)
node.cornerRadius = StatusNode.avatarImageCornerRadius
// node.cornerRoundingType = .precomposited
return node
}()
let nameTextNode = ASTextNode()
let nameDotTextNode = ASTextNode()
let dateTextNode = ASTextNode()
let usernameTextNode = ASTextNode()
init(status: Status) {
super.init()
automaticallyManagesSubnodes = true
if let url = (status.reblog ?? status).author.avatarImageURL() {
avatarImageNode.url = url
}
nameTextNode.attributedText = NSAttributedString(string: status.author.displayNameWithFallback, attributes: [
.foregroundColor: Asset.Colors.Label.primary.color,
.font: UIFont.systemFont(ofSize: 17, weight: .semibold)
])
nameDotTextNode.attributedText = NSAttributedString(string: "·", attributes: [
.foregroundColor: Asset.Colors.Label.secondary.color,
.font: UIFont.systemFont(ofSize: 13, weight: .regular)
])
// set date
let createdAt = (status.reblog ?? status).createdAt
dateTextNode.attributedText = NSAttributedString(string: createdAt.slowedTimeAgoSinceNow, attributes: [
.foregroundColor: Asset.Colors.Label.secondary.color,
.font: UIFont.systemFont(ofSize: 13, weight: .regular)
])
// RunLoop.main.perform { [weak self] in
// guard let self = self else { return }
// AppContext.shared.timestampUpdatePublisher
// .sink { [weak self] _ in
// guard let self = self else { return }
// self.dateTextNode.attributedText = NSAttributedString(string: createdAt.slowedTimeAgoSinceNow, attributes: [
// .foregroundColor: Asset.Colors.Label.secondary.color,
// .font: UIFont.systemFont(ofSize: 13, weight: .regular)
// ])
// }
// .store(in: &self.disposeBag)
// }
usernameTextNode.attributedText = NSAttributedString(string: "@" + status.author.acct, attributes: [
.foregroundColor: Asset.Colors.Label.secondary.color,
.font: UIFont.systemFont(ofSize: 15, weight: .regular)
])
}
override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
let headerStack = ASStackLayoutSpec.horizontal()
headerStack.alignItems = .center
headerStack.spacing = 5
var headerStackChildren: [ASLayoutElement] = []
avatarImageNode.style.preferredSize = StatusNode.avatarImageSize
headerStackChildren.append(avatarImageNode)
let authorMetaHeaderStack = ASStackLayoutSpec.horizontal()
authorMetaHeaderStack.alignItems = .center
authorMetaHeaderStack.spacing = 4
authorMetaHeaderStack.children = [
nameTextNode,
nameDotTextNode,
dateTextNode,
]
let authorMetaStack = ASStackLayoutSpec.vertical()
authorMetaStack.children = [
authorMetaHeaderStack,
usernameTextNode,
]
headerStackChildren.append(authorMetaStack)
headerStack.children = headerStackChildren
let verticalStack = ASStackLayoutSpec.vertical()
verticalStack.children = [
headerStack
]
return verticalStack
}
}

View File

@ -0,0 +1,37 @@
//
// TimelineBottomLoaderNode.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-6-19.
//
import UIKit
import AsyncDisplayKit
final class TimelineBottomLoaderNode: ASCellNode {
let activityIndicatorNode = ActivityIndicatorNode()
override init() {
super.init()
automaticallyManagesSubnodes = true
activityIndicatorNode.bounds = CGRect(x: 0, y: 0, width: 40, height: 40)
}
override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
let contentStack = ASStackLayoutSpec.horizontal()
contentStack.alignItems = .center
contentStack.spacing = 7
contentStack.children = [activityIndicatorNode]
return contentStack
}
override func didEnterDisplayState() {
super.didEnterDisplayState()
activityIndicatorNode.animating = true
}
}

View File

@ -0,0 +1,50 @@
//
// TimelineMiddleLoaderNode.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-6-19.
//
import UIKit
import AsyncDisplayKit
final class TimelineMiddleLoaderNode: ASCellNode {
static let loadButtonFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .medium))
let activityIndicatorNode = ASDisplayNode(viewBlock: {
let view = UIActivityIndicatorView(style: .medium)
view.hidesWhenStopped = true
return view
})
let loadButtonNode = ASButtonNode()
override init() {
super.init()
automaticallyManagesSubnodes = true
loadButtonNode.setAttributedTitle(
NSAttributedString(
string: L10n.Common.Controls.Timeline.Loader.loadMissingPosts,
attributes: [
.foregroundColor: Asset.Colors.brandBlue.color,
.font: TimelineMiddleLoaderNode.loadButtonFont
]),
for: .normal
)
}
override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
let contentStack = ASStackLayoutSpec.horizontal()
contentStack.alignItems = .center
contentStack.spacing = 7
contentStack.children = [loadButtonNode]
return contentStack
}
}

View File

@ -44,6 +44,10 @@ class AppContext: ObservableObject {
private var documentStoreSubscription: AnyCancellable!
let overrideTraitCollection = CurrentValueSubject<UITraitCollection?, Never>(nil)
let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.share()
.eraseToAnyPublisher()
init() {
let _coreDataStack = CoreDataStack()

View File

@ -9,6 +9,7 @@ import os.log
import UIKit
import UserNotifications
import AppShared
import AsyncDisplayKit
#if DEBUG
import GDPerformanceView_Swift
@ -33,6 +34,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
#if DEBUG
PerformanceMonitor.shared().start()
// ASDisplayNode.shouldShowRangeDebugOverlay = true
#endif
return true

View File

@ -0,0 +1,71 @@
// ref: https://github.com/Adlai-Holler/ASDKPlaceholderTest/blob/eea9fa7cff2d16a57efb47d208422ea9b49a630a/ASDKPlaceholderTest/ASDisplayNodeSubclasses.swift
import Foundation
import AsyncDisplayKit
import UIKit
/**
A node that shows a `UIActivityIndicatorView`. Does not support layer backing.
Note: You must not change the style to or from `.WhiteLarge` after init, or the node's size will not update.
*/
class ActivityIndicatorNode: ASDisplayNode {
private static let defaultSize = CGSize(width: 20, height: 20)
private static let largeSize = CGSize(width: 37, height: 37)
init(style: UIActivityIndicatorView.Style = .medium) {
super.init()
setViewBlock {
UIActivityIndicatorView(style: style)
}
self.style.preferredSize = style == .large ? ActivityIndicatorNode.defaultSize : ActivityIndicatorNode.largeSize
}
var activityIndicatorView: UIActivityIndicatorView {
return view as! UIActivityIndicatorView
}
override func didLoad() {
super.didLoad()
if animating {
activityIndicatorView.startAnimating()
}
activityIndicatorView.color = color
activityIndicatorView.hidesWhenStopped = hidesWhenStopped
}
/// Wrapper for `UIActivityIndicatorView.hidesWhenStopped`. NOTE: You must respect thread affinity.
var hidesWhenStopped = true {
didSet {
if isNodeLoaded {
assert(Thread.isMainThread)
activityIndicatorView.hidesWhenStopped = hidesWhenStopped
}
}
}
/// Wrapper for `UIActivityIndicatorView.color`. NOTE: You must respect thread affinity.
var color: UIColor? {
didSet {
if isNodeLoaded {
assert(Thread.isMainThread)
activityIndicatorView.color = color
}
}
}
/// Wrapper for `UIActivityIndicatorView.animating`. NOTE: You must respect thread affinity.
var animating = false {
didSet {
if isNodeLoaded {
assert(Thread.isMainThread)
if animating {
activityIndicatorView.startAnimating()
} else {
activityIndicatorView.stopAnimating()
}
}
}
}
}

View File

@ -8,7 +8,8 @@ target 'Mastodon' do
# UI
pod 'UITextField+Shake', '~> 1.2'
pod 'Texture', '~> 3.0.0'
# misc
pod 'SwiftGen', '~> 6.4.0'
pod 'DateToolsSwift', '~> 5.0.0'

View File

@ -4,7 +4,42 @@ PODS:
- GDPerformanceView-Swift (2.1.1)
- Kanna (5.2.4)
- Keys (1.0.1)
- PINCache (3.0.3):
- PINCache/Arc-exception-safe (= 3.0.3)
- PINCache/Core (= 3.0.3)
- PINCache/Arc-exception-safe (3.0.3):
- PINCache/Core
- PINCache/Core (3.0.3):
- PINOperation (~> 1.2.1)
- PINOperation (1.2.1)
- PINRemoteImage/Core (3.0.3):
- PINOperation
- PINRemoteImage/iOS (3.0.3):
- PINRemoteImage/Core
- PINRemoteImage/PINCache (3.0.3):
- PINCache (~> 3.0.3)
- PINRemoteImage/Core
- SwiftGen (6.4.0)
- Texture (3.0.0):
- Texture/AssetsLibrary (= 3.0.0)
- Texture/Core (= 3.0.0)
- Texture/MapKit (= 3.0.0)
- Texture/Photos (= 3.0.0)
- Texture/PINRemoteImage (= 3.0.0)
- Texture/Video (= 3.0.0)
- Texture/AssetsLibrary (3.0.0):
- Texture/Core
- Texture/Core (3.0.0)
- Texture/MapKit (3.0.0):
- Texture/Core
- Texture/Photos (3.0.0):
- Texture/Core
- Texture/PINRemoteImage (3.0.0):
- PINRemoteImage/iOS (~> 3.0.0)
- PINRemoteImage/PINCache
- Texture/Core
- Texture/Video (3.0.0):
- Texture/Core
- "UITextField+Shake (1.2.1)"
DEPENDENCIES:
@ -14,6 +49,7 @@ DEPENDENCIES:
- Kanna (~> 5.2.2)
- Keys (from `Pods/CocoaPodsKeys`)
- SwiftGen (~> 6.4.0)
- Texture
- "UITextField+Shake (~> 1.2)"
SPEC REPOS:
@ -22,7 +58,11 @@ SPEC REPOS:
- FLEX
- GDPerformanceView-Swift
- Kanna
- PINCache
- PINOperation
- PINRemoteImage
- SwiftGen
- Texture
- "UITextField+Shake"
EXTERNAL SOURCES:
@ -35,9 +75,13 @@ SPEC CHECKSUMS:
GDPerformanceView-Swift: 22d964fe40b19e3d914dba2586237d064de8fd77
Kanna: b9d00d7c11428308c7f95e1f1f84b8205f567a8f
Keys: a576f4c9c1c641ca913a959a9c62ed3f215a8de9
PINCache: 7a8fc1a691173d21dbddbf86cd515de6efa55086
PINOperation: 00c935935f1e8cf0d1e2d6b542e75b88fc3e5e20
PINRemoteImage: f1295b29f8c5e640e25335a1b2bd9d805171bd01
SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108
Texture: 2f109e937850d94d1d07232041c9c7313ccddb81
"UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3
PODFILE CHECKSUM: 257c550231fcd1336a29f7835aa331171bb66ebd
PODFILE CHECKSUM: 464046172607e3a92ad500f8050ee34566a47c73
COCOAPODS: 1.10.1