diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 4fa40a99d..216a5ba34 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -124,6 +124,7 @@ 51C452B82265178500C03939 /* styleSheet.css in Resources */ = {isa = PBXBuildFile; fileRef = 51C452B72265178500C03939 /* styleSheet.css */; }; 51CC9B3E231720B2000E842F /* MasterFeedDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51CC9B3D231720B2000E842F /* MasterFeedDataSource.swift */; }; 51D5948722668EFA00DFC836 /* MarkStatusCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84702AA31FA27AC0006B8943 /* MarkStatusCommand.swift */; }; + 51D6A5BC23199C85001C27D8 /* MasterTimelineDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D6A5BB23199C85001C27D8 /* MasterTimelineDataSource.swift */; }; 51D87EE12311D34700E63F03 /* ActivityType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D87EE02311D34700E63F03 /* ActivityType.swift */; }; 51E3EB33229AB02C00645299 /* ErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E3EB32229AB02C00645299 /* ErrorHandler.swift */; }; 51E3EB3D229AB08300645299 /* ErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E3EB3C229AB08300645299 /* ErrorHandler.swift */; }; @@ -735,6 +736,7 @@ 51C452B32265141B00C03939 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS12.2.sdk/System/Library/Frameworks/WebKit.framework; sourceTree = DEVELOPER_DIR; }; 51C452B72265178500C03939 /* styleSheet.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = styleSheet.css; sourceTree = ""; }; 51CC9B3D231720B2000E842F /* MasterFeedDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterFeedDataSource.swift; sourceTree = ""; }; + 51D6A5BB23199C85001C27D8 /* MasterTimelineDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterTimelineDataSource.swift; sourceTree = ""; }; 51D87EE02311D34700E63F03 /* ActivityType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityType.swift; sourceTree = ""; }; 51E3EB32229AB02C00645299 /* ErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorHandler.swift; sourceTree = ""; }; 51E3EB3C229AB08300645299 /* ErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorHandler.swift; sourceTree = ""; }; @@ -1157,6 +1159,7 @@ isa = PBXGroup; children = ( 51C4526E2265091600C03939 /* MasterTimelineViewController.swift */, + 51D6A5BB23199C85001C27D8 /* MasterTimelineDataSource.swift */, 51C4526F2265091600C03939 /* Cell */, ); path = MasterTimeline; @@ -2479,6 +2482,7 @@ 51C452782265091600C03939 /* MasterTimelineCellData.swift in Sources */, 51C45259226508D300C03939 /* AppDefaults.swift in Sources */, 51C45293226509C800C03939 /* StarredFeedDelegate.swift in Sources */, + 51D6A5BC23199C85001C27D8 /* MasterTimelineDataSource.swift in Sources */, 51934CCB230F599B006127BE /* ThemedNavigationController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/iOS/AppCoordinator.swift b/iOS/AppCoordinator.swift index d9dddaa00..f80568f9c 100644 --- a/iOS/AppCoordinator.swift +++ b/iOS/AppCoordinator.swift @@ -468,11 +468,17 @@ class AppCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { animatingChanges = false } + func indexForArticleID(_ articleID: String?) -> Int? { + guard let articleID = articleID else { return nil } + updateArticleRowMapIfNeeded() + return articleRowMap[articleID] + } + func indexesForArticleIDs(_ articleIDs: Set) -> IndexSet { var indexes = IndexSet() articleIDs.forEach { (articleID) in - guard let oneIndex = row(for: articleID) else { + guard let oneIndex = indexForArticleID(articleID) else { return } if oneIndex != NSNotFound { @@ -482,7 +488,7 @@ class AppCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { return indexes } - + func selectFeed(_ indexPath: IndexPath) { if navControllerForTimeline().viewControllers.filter({ $0 is MasterTimelineViewController }).count > 0 { currentMasterIndexPath = indexPath @@ -885,17 +891,12 @@ private extension AppCoordinator { if articles != sortedArticles { let article = currentArticle articles = sortedArticles - if let articleID = article?.articleID, let index = row(for: articleID) { + if let articleID = article?.articleID, let index = indexForArticleID(articleID) { currentArticleIndexPath = IndexPath(row: index, section: 0) } } } - func row(for articleID: String) -> Int? { - updateArticleRowMapIfNeeded() - return articleRowMap[articleID] - } - func updateArticleRowMap() { var rowMap = [String: Int]() var index = 0 diff --git a/iOS/MasterTimeline/MasterTimelineDataSource.swift b/iOS/MasterTimeline/MasterTimelineDataSource.swift new file mode 100644 index 000000000..430dab928 --- /dev/null +++ b/iOS/MasterTimeline/MasterTimelineDataSource.swift @@ -0,0 +1,25 @@ +// +// MasterTimelineDataSource.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 8/30/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import UIKit + +class MasterTimelineDataSource: UITableViewDiffableDataSource where SectionIdentifierType : Hashable, ItemIdentifierType : Hashable { + + private var coordinator: AppCoordinator! + + init(coordinator: AppCoordinator, tableView: UITableView, cellProvider: @escaping UITableViewDiffableDataSource.CellProvider) { + super.init(tableView: tableView, cellProvider: cellProvider) + self.coordinator = coordinator + } + + override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + return true + } + +} + diff --git a/iOS/MasterTimeline/MasterTimelineViewController.swift b/iOS/MasterTimeline/MasterTimelineViewController.swift index a665ba69e..b23af9961 100644 --- a/iOS/MasterTimeline/MasterTimelineViewController.swift +++ b/iOS/MasterTimeline/MasterTimelineViewController.swift @@ -18,6 +18,7 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner @IBOutlet weak var markAllAsReadButton: UIBarButtonItem! @IBOutlet weak var firstUnreadButton: UIBarButtonItem! + private lazy var dataSource = makeDataSource() weak var coordinator: AppCoordinator! var undoableCommands = [UndoableCommand]() @@ -28,7 +29,8 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner override func viewDidLoad() { super.viewDidLoad() - + tableView.dataSource = dataSource + NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(statusesDidChange(_:)), name: .StatusesDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .FeedIconDidBecomeAvailable, object: nil) @@ -44,6 +46,7 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner numberOfTextLines = AppDefaults.timelineNumberOfLines resetEstimatedRowHeight() + applyChanges(animate: false) resetUI() } @@ -71,8 +74,10 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner appDelegate.authorAvatarDownloader.resetCache() appDelegate.feedIconDownloader.resetCache() appDelegate.faviconDownloader.resetCache() - performBlockAndRestoreSelection { - tableView.reloadData() + + // traitCollectionDidChange will get called on a background thread + DispatchQueue.main.async { + self.reloadAllVisibleCells() } } } @@ -115,8 +120,8 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner } func reloadArticles() { - performBlockAndRestoreSelection { - tableView.reloadData() + applyChanges(animate: true) { [weak self] in + self?.updateArticleSelection() } } @@ -131,14 +136,6 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner // MARK: - Table view - override func numberOfSections(in tableView: UITableView) -> Int { - return 1 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return coordinator.articles.count - } - override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let article = coordinator.articles[indexPath.row] @@ -251,13 +248,6 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner } - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! MasterTimelineTableViewCell - let article = coordinator.articles[indexPath.row] - configureTimelineCell(cell, article: article) - return cell - } - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { coordinator.selectArticle(indexPath) } @@ -279,15 +269,12 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner guard let feed = note.userInfo?[UserInfoKey.feed] as? Feed else { return } - - performBlockAndRestoreSelection { - tableView.indexPathsForVisibleRows?.forEach { indexPath in - guard let article = coordinator.articles.articleAtRow(indexPath.row) else { - return - } - if article.feed == feed, let cell = tableView.cellForRow(at: indexPath) as? MasterTimelineTableViewCell, let image = avatarFor(article) { - cell.setAvatarImage(image) - } + tableView.indexPathsForVisibleRows?.forEach { indexPath in + guard let article = coordinator.articles.articleAtRow(indexPath.row) else { + return + } + if article.feed == feed, let cell = tableView.cellForRow(at: indexPath) as? MasterTimelineTableViewCell, let image = avatarFor(article) { + cell.setAvatarImage(image) } } } @@ -296,16 +283,13 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner guard coordinator.showAvatars, let avatarURL = note.userInfo?[UserInfoKey.url] as? String else { return } - - performBlockAndRestoreSelection { - tableView.indexPathsForVisibleRows?.forEach { indexPath in - guard let article = coordinator.articles.articleAtRow(indexPath.row), let authors = article.authors, !authors.isEmpty else { - return - } - for author in authors { - if author.avatarURL == avatarURL, let cell = tableView.cellForRow(at: indexPath) as? MasterTimelineTableViewCell, let image = avatarFor(article) { - cell.setAvatarImage(image) - } + tableView.indexPathsForVisibleRows?.forEach { indexPath in + guard let article = coordinator.articles.articleAtRow(indexPath.row), let authors = article.authors, !authors.isEmpty else { + return + } + for author in authors { + if author.avatarURL == avatarURL, let cell = tableView.cellForRow(at: indexPath) as? MasterTimelineTableViewCell, let image = avatarFor(article) { + cell.setAvatarImage(image) } } } @@ -315,18 +299,13 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner guard coordinator.showAvatars, let faviconURL = note.userInfo?["faviconURL"] as? String else { return } - - performBlockAndRestoreSelection { - tableView.indexPathsForVisibleRows?.forEach { indexPath in - - guard let article = coordinator.articles.articleAtRow(indexPath.row), let articleFaviconURL = article.feed?.faviconURL else { - return - } - if faviconURL == articleFaviconURL, let cell = tableView.cellForRow(at: indexPath) as? MasterTimelineTableViewCell, let image = avatarFor(article) { - cell.setAvatarImage(image) - return - } - + tableView.indexPathsForVisibleRows?.forEach { indexPath in + guard let article = coordinator.articles.articleAtRow(indexPath.row), let articleFaviconURL = article.feed?.faviconURL else { + return + } + if faviconURL == articleFaviconURL, let cell = tableView.cellForRow(at: indexPath) as? MasterTimelineTableViewCell, let image = avatarFor(article) { + cell.setAvatarImage(image) + return } } } @@ -335,12 +314,12 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner if numberOfTextLines != AppDefaults.timelineNumberOfLines { numberOfTextLines = AppDefaults.timelineNumberOfLines resetEstimatedRowHeight() - tableView.reloadData() + reloadAllVisibleCells() } } @objc func contentSizeCategoryDidChange(_ note: Notification) { - tableView.reloadData() + reloadAllVisibleCells() } @objc func progressDidChange(_ note: Notification) { @@ -349,12 +328,9 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner // MARK: Reloading - @objc func reloadAllVisibleCells() { - tableView.beginUpdates() - performBlockAndRestoreSelection { - tableView.reloadRows(at: tableView.indexPathsForVisibleRows!, with: .none) - } - tableView.endUpdates() + private func reloadAllVisibleCells() { + let visibleArticles = tableView.indexPathsForVisibleRows!.map { return coordinator.articles[$0.row] } + reloadCells(visibleArticles) } private func reloadVisibleCells(for articles: [Article]) { @@ -374,13 +350,22 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner } private func reloadVisibleCells(for indexes: IndexSet) { - performBlockAndRestoreSelection { - tableView.indexPathsForVisibleRows?.forEach { indexPath in - if indexes.contains(indexPath.row) { - tableView.reloadRows(at: [indexPath], with: .none) - } + let reloadArticles: [Article] = tableView.indexPathsForVisibleRows!.compactMap { indexPath in + if indexes.contains(indexPath.row) { + return coordinator.articles[indexPath.row] + } else { + return nil } } + reloadCells(reloadArticles) + } + + private func reloadCells(_ articles: [Article]) { + var snapshot = dataSource.snapshot() + snapshot.reloadItems(articles) + dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in + self?.restoreSelectionIfNecessary() + } } // MARK: Cell Configuring @@ -444,8 +429,27 @@ private extension MasterTimelineViewController { navigationController?.updateAccountRefreshProgressIndicator() } } + + func applyChanges(animate: Bool, completion: (() -> Void)? = nil) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(coordinator.articles, toSection: 0) + + dataSource.apply(snapshot, animatingDifferences: animate) { [weak self] in + self?.restoreSelectionIfNecessary() + completion?() + } + } - func configureTimelineCell(_ cell: MasterTimelineTableViewCell, article: Article) { + func makeDataSource() -> UITableViewDiffableDataSource { + return MasterTimelineDataSource(coordinator: coordinator, tableView: tableView, cellProvider: { [weak self] tableView, indexPath, article in + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! MasterTimelineTableViewCell + self?.configure(cell, article: article) + return cell + }) + } + + func configure(_ cell: MasterTimelineTableViewCell, article: Article) { let avatar = avatarFor(article) let featuredImage = featuredImageFor(article) @@ -470,19 +474,11 @@ private extension MasterTimelineViewController { return nil } - func queueReloadVisableCells() { - CoalescingQueue.standard.add(self, #selector(reloadAllVisibleCells)) - } - - func performBlockAndRestoreSelection(_ block: (() -> Void)) { + func restoreSelectionIfNecessary() { guard traitCollection.userInterfaceIdiom == .pad else { - block() return } - - let articleID = coordinator.currentArticle?.articleID - block() - if let articleID = articleID, let index = coordinator.indexesForArticleIDs(Set([articleID])).first { + if let articleID = coordinator.currentArticle?.articleID, let index = coordinator.indexesForArticleIDs(Set([articleID])).first { let indexPath = IndexPath(row: index, section: 0) tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) }