From bbc7230e76a0fd8b8e7b48e5c5c292a284b0deda Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Wed, 20 Oct 2021 19:03:02 -0500 Subject: [PATCH] Refactor Diffable Datasources out of the Sidebar --- Account/Sources/Account/Feed.swift | 1 + NetNewsWire.xcodeproj/project.pbxproj | 24 +- Shared/SmartFeeds/SmartFeed.swift | 2 + Shared/SmartFeeds/UnreadFeed.swift | 2 + .../Cell/MasterFeedRowIdentifier.swift | 23 ++ iOS/MasterFeed/MasterFeedDataSource.swift | 22 - .../MasterFeedDataSourceOperation.swift | 39 -- .../MasterFeedViewController+Drag.swift | 6 +- .../MasterFeedViewController+Drop.swift | 51 +-- iOS/MasterFeed/MasterFeedViewController.swift | 385 ++++++++---------- .../MasterTimelineViewController.swift | 2 +- iOS/SceneCoordinator.swift | 149 ++++--- iOS/ShadowTableChanges.swift | 65 +++ 13 files changed, 382 insertions(+), 389 deletions(-) create mode 100644 iOS/MasterFeed/Cell/MasterFeedRowIdentifier.swift delete mode 100644 iOS/MasterFeed/MasterFeedDataSource.swift delete mode 100644 iOS/MasterFeed/MasterFeedDataSourceOperation.swift create mode 100644 iOS/ShadowTableChanges.swift diff --git a/Account/Sources/Account/Feed.swift b/Account/Sources/Account/Feed.swift index 958b25106..23d842f0a 100644 --- a/Account/Sources/Account/Feed.swift +++ b/Account/Sources/Account/Feed.swift @@ -17,6 +17,7 @@ public enum ReadFilterType { public protocol Feed: FeedIdentifiable, ArticleFetcher, DisplayNameProvider, UnreadCountProvider { + var account: Account? { get } var defaultReadFilterType: ReadFilterType { get } } diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index f32bb0df6..483ba78c1 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -183,7 +183,6 @@ 513C5CE9232571C2003D4054 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 513C5CE8232571C2003D4054 /* ShareViewController.swift */; }; 513C5CEC232571C2003D4054 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 513C5CEA232571C2003D4054 /* MainInterface.storyboard */; }; 513C5CF0232571C2003D4054 /* NetNewsWire iOS Share Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 513C5CE6232571C2003D4054 /* NetNewsWire iOS Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 513CCF2524880C1500C55709 /* MasterFeedTableViewIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 513CCF08248808BA00C55709 /* MasterFeedTableViewIdentifier.swift */; }; 513F325C2593ECF40003048F /* RSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 513F325B2593ECF40003048F /* RSCore */; }; 513F325D2593ECF40003048F /* RSCore in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 513F325B2593ECF40003048F /* RSCore */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 513F32712593EE6F0003048F /* Articles in Frameworks */ = {isa = PBXBuildFile; productRef = 513F32702593EE6F0003048F /* Articles */; }; @@ -242,7 +241,6 @@ 516244E3241E19F000B61C47 /* ColorPaletteTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 516244E2241E19F000B61C47 /* ColorPaletteTableViewController.swift */; }; 51627A6723861DA3007B3B4B /* MasterFeedViewController+Drag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51627A6623861DA3007B3B4B /* MasterFeedViewController+Drag.swift */; }; 51627A6923861DED007B3B4B /* MasterFeedViewController+Drop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51627A6823861DED007B3B4B /* MasterFeedViewController+Drop.swift */; }; - 51627A6B238629D8007B3B4B /* MasterFeedDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51627A6A238629D8007B3B4B /* MasterFeedDataSource.swift */; }; 51627A93238A3836007B3B4B /* CroppingPreviewParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51627A92238A3836007B3B4B /* CroppingPreviewParameters.swift */; }; 516A093723609A3600EAE89B /* SettingsComboTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 516A091D23609A3600EAE89B /* SettingsComboTableViewCell.xib */; }; 516A09392360A2AE00EAE89B /* SettingsComboTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 516A09382360A2AE00EAE89B /* SettingsComboTableViewCell.swift */; }; @@ -281,6 +279,8 @@ 5193CD58245E44A90092735E /* RedditFeedProvider-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5193CD57245E44A90092735E /* RedditFeedProvider-Extensions.swift */; }; 5193CD59245E44A90092735E /* RedditFeedProvider-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5193CD57245E44A90092735E /* RedditFeedProvider-Extensions.swift */; }; 5193CD5A245E44A90092735E /* RedditFeedProvider-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5193CD57245E44A90092735E /* RedditFeedProvider-Extensions.swift */; }; + 5195C1DA2720205F00888867 /* ShadowTableChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5195C1D92720205F00888867 /* ShadowTableChanges.swift */; }; + 5195C1DC2720BD3000888867 /* MasterFeedRowIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5195C1DB2720BD3000888867 /* MasterFeedRowIdentifier.swift */; }; 519B8D332143397200FA689C /* SharingServiceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519B8D322143397200FA689C /* SharingServiceDelegate.swift */; }; 519CA8E525841DB700EB079A /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; productRef = 519CA8E425841DB700EB079A /* CrashReporter */; }; 519E743D22C663F900A78E47 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E743422C663F900A78E47 /* SceneDelegate.swift */; }; @@ -409,8 +409,6 @@ 51DC07992552083500A3F79F /* ArticleTextSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51DC07972552083500A3F79F /* ArticleTextSize.swift */; }; 51DC079A2552083500A3F79F /* ArticleTextSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51DC07972552083500A3F79F /* ArticleTextSize.swift */; }; 51DC07AC255209E200A3F79F /* ArticleTextSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51DC07972552083500A3F79F /* ArticleTextSize.swift */; }; - 51DC37072402153E0095D371 /* UpdateSelectionOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51DC37062402153E0095D371 /* UpdateSelectionOperation.swift */; }; - 51DC37092402F1470095D371 /* MasterFeedDataSourceOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51DC37082402F1470095D371 /* MasterFeedDataSourceOperation.swift */; }; 51DC370B2405BC9A0095D371 /* PreloadedWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51DC370A2405BC9A0095D371 /* PreloadedWebView.swift */; }; 51DEE81226FB9233006DAA56 /* Appanoose.nnwtheme in Resources */ = {isa = PBXBuildFile; fileRef = 51DEE81126FB9233006DAA56 /* Appanoose.nnwtheme */; }; 51DEE81326FB9233006DAA56 /* Appanoose.nnwtheme in Resources */ = {isa = PBXBuildFile; fileRef = 51DEE81126FB9233006DAA56 /* Appanoose.nnwtheme */; }; @@ -1214,7 +1212,6 @@ 513C5CE8232571C2003D4054 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; 513C5CEB232571C2003D4054 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; 513C5CED232571C2003D4054 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 513CCF08248808BA00C55709 /* MasterFeedTableViewIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterFeedTableViewIdentifier.swift; sourceTree = ""; }; 5141E7382373C18B0013FF27 /* WebFeedInspectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebFeedInspectorViewController.swift; sourceTree = ""; }; 5141E7552374A2890013FF27 /* DetailIconSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailIconSchemeHandler.swift; sourceTree = ""; }; 5142192923522B5500E07E2C /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = ""; }; @@ -1245,7 +1242,6 @@ 516244E2241E19F000B61C47 /* ColorPaletteTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPaletteTableViewController.swift; sourceTree = ""; }; 51627A6623861DA3007B3B4B /* MasterFeedViewController+Drag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MasterFeedViewController+Drag.swift"; sourceTree = ""; }; 51627A6823861DED007B3B4B /* MasterFeedViewController+Drop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MasterFeedViewController+Drop.swift"; sourceTree = ""; }; - 51627A6A238629D8007B3B4B /* MasterFeedDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MasterFeedDataSource.swift; sourceTree = ""; }; 51627A92238A3836007B3B4B /* CroppingPreviewParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CroppingPreviewParameters.swift; sourceTree = ""; }; 516A091D23609A3600EAE89B /* SettingsComboTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = SettingsComboTableViewCell.xib; sourceTree = ""; }; 516A09382360A2AE00EAE89B /* SettingsComboTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsComboTableViewCell.swift; sourceTree = ""; }; @@ -1279,6 +1275,8 @@ 51934CCD2310792F006127BE /* ActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityManager.swift; sourceTree = ""; }; 51938DF1231AFC660055A1A0 /* SearchTimelineFeedDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTimelineFeedDelegate.swift; sourceTree = ""; }; 5193CD57245E44A90092735E /* RedditFeedProvider-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RedditFeedProvider-Extensions.swift"; sourceTree = ""; }; + 5195C1D92720205F00888867 /* ShadowTableChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowTableChanges.swift; sourceTree = ""; }; + 5195C1DB2720BD3000888867 /* MasterFeedRowIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterFeedRowIdentifier.swift; sourceTree = ""; }; 519B8D322143397200FA689C /* SharingServiceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharingServiceDelegate.swift; sourceTree = ""; }; 519E743422C663F900A78E47 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 519ED455244828C3007F8E94 /* AddExtensionPointViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExtensionPointViewController.swift; sourceTree = ""; }; @@ -1343,8 +1341,6 @@ 51D6A5BB23199C85001C27D8 /* MasterTimelineDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterTimelineDataSource.swift; sourceTree = ""; }; 51D87EE02311D34700E63F03 /* ActivityType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityType.swift; sourceTree = ""; }; 51DC07972552083500A3F79F /* ArticleTextSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleTextSize.swift; sourceTree = ""; }; - 51DC37062402153E0095D371 /* UpdateSelectionOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateSelectionOperation.swift; sourceTree = ""; }; - 51DC37082402F1470095D371 /* MasterFeedDataSourceOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterFeedDataSourceOperation.swift; sourceTree = ""; }; 51DC370A2405BC9A0095D371 /* PreloadedWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreloadedWebView.swift; sourceTree = ""; }; 51DEE81126FB9233006DAA56 /* Appanoose.nnwtheme */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Appanoose.nnwtheme; sourceTree = ""; }; 51DEE81726FBFF84006DAA56 /* Promenade.nnwtheme */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Promenade.nnwtheme; sourceTree = ""; }; @@ -2073,11 +2069,8 @@ 51C45264226508F600C03939 /* MasterFeedViewController.swift */, 51627A6623861DA3007B3B4B /* MasterFeedViewController+Drag.swift */, 51627A6823861DED007B3B4B /* MasterFeedViewController+Drop.swift */, - 51627A6A238629D8007B3B4B /* MasterFeedDataSource.swift */, - 51DC37082402F1470095D371 /* MasterFeedDataSourceOperation.swift */, 51CE1C0A23622006005548FC /* RefreshProgressView.swift */, 51CE1C0823621EDA005548FC /* RefreshProgressView.xib */, - 51DC37062402153E0095D371 /* UpdateSelectionOperation.swift */, 51C45260226508F600C03939 /* Cell */, ); path = MasterFeed; @@ -2086,9 +2079,9 @@ 51C45260226508F600C03939 /* Cell */ = { isa = PBXGroup; children = ( + 5195C1DB2720BD3000888867 /* MasterFeedRowIdentifier.swift */, 51C45262226508F600C03939 /* MasterFeedTableViewCell.swift */, 51C45263226508F600C03939 /* MasterFeedTableViewCellLayout.swift */, - 513CCF08248808BA00C55709 /* MasterFeedTableViewIdentifier.swift */, 512E08F722688F7C00BDCFDD /* MasterFeedTableViewSectionHeader.swift */, 516AE9B22371C372007DEEAA /* MasterFeedTableViewSectionHeaderLayout.swift */, 51C45261226508F600C03939 /* MasterFeedUnreadCountView.swift */, @@ -2698,6 +2691,7 @@ 840D617E2029031C009BC708 /* AppDelegate.swift */, 519E743422C663F900A78E47 /* SceneDelegate.swift */, 5126EE96226CB48A00C22AFC /* SceneCoordinator.swift */, + 5195C1D92720205F00888867 /* ShadowTableChanges.swift */, 514B7C8223205EFB00BAC947 /* RootSplitViewController.swift */, 511D4410231FC02D00FB1562 /* KeyboardManager.swift */, 51C45254226507D200C03939 /* AppAssets.swift */, @@ -4054,6 +4048,7 @@ 51C452A422650A2D00C03939 /* ArticleUtilities.swift in Sources */, 51EF0F79227716380050506E /* ColorHash.swift in Sources */, 51F9F3FB23DFB25700A314FD /* Animations.swift in Sources */, + 5195C1DA2720205F00888867 /* ShadowTableChanges.swift in Sources */, 5183CCDA226E31A50010922C /* NonIntrinsicImageView.swift in Sources */, B2B80778239C4C7000F191E0 /* RSImage-AppIcons.swift in Sources */, 518ED21D23D0F26000E0A862 /* UIViewController-Extensions.swift in Sources */, @@ -4146,7 +4141,6 @@ 51C452AF2265108300C03939 /* ArticleArray.swift in Sources */, 51C4528E2265099C00C03939 /* SmartFeedsController.swift in Sources */, 51C9DE5823EA2EF4003D5A6D /* WrapperScriptMessageHandler.swift in Sources */, - 51627A6B238629D8007B3B4B /* MasterFeedDataSource.swift in Sources */, 51B5C87D23F2346200032075 /* ExtensionContainersFile.swift in Sources */, 51102165233A7D6C0007A5F7 /* ArticleExtractorButton.swift in Sources */, 5141E7392373C18B0013FF27 /* WebFeedInspectorViewController.swift in Sources */, @@ -4178,6 +4172,7 @@ 512D554423C804DE0023FFFA /* OpenInSafariActivity.swift in Sources */, 512392C224E33A3C00F11704 /* RedditEnterDetailTableViewController.swift in Sources */, 51C452762265091600C03939 /* MasterTimelineViewController.swift in Sources */, + 5195C1DC2720BD3000888867 /* MasterFeedRowIdentifier.swift in Sources */, 5108F6D823763094001ABC45 /* TickMarkSlider.swift in Sources */, 51C452882265093600C03939 /* AddFeedViewController.swift in Sources */, 51B5C8C023F3866C00032075 /* ExtensionFeedAddRequestFile.swift in Sources */, @@ -4196,7 +4191,6 @@ B24E9ADE245AB88400DA5718 /* NSAttributedString+NetNewsWire.swift in Sources */, C5A6ED5223C9AF4300AB6BE2 /* TitleActivityItemSource.swift in Sources */, 17D7586F2679C21800B17787 /* OnePasswordExtension.m in Sources */, - 51DC37092402F1470095D371 /* MasterFeedDataSourceOperation.swift in Sources */, 17071EF126F8137400F5E71D /* ArticleTheme+Notifications.swift in Sources */, 51C4529B22650A1000C03939 /* FaviconDownloader.swift in Sources */, 84DEE56622C32CA4005FC42C /* SmartFeedDelegate.swift in Sources */, @@ -4214,7 +4208,6 @@ FFD43E412340F488009E5CA3 /* MarkAsReadAlertController.swift in Sources */, 51C452A322650A1E00C03939 /* HTMLMetadataDownloader.swift in Sources */, 51C4528D2265095F00C03939 /* AddFolderViewController.swift in Sources */, - 51DC37072402153E0095D371 /* UpdateSelectionOperation.swift in Sources */, 51C452782265091600C03939 /* MasterTimelineCellData.swift in Sources */, 5148F4552336DB7000F8CD8B /* MasterTimelineTitleView.swift in Sources */, 515A517C243E90260089E588 /* ExtensionPointManager.swift in Sources */, @@ -4226,7 +4219,6 @@ 511D4419231FC02D00FB1562 /* KeyboardManager.swift in Sources */, 51A1699D235E10D700EB091F /* SettingsViewController.swift in Sources */, 51C45293226509C800C03939 /* StarredFeedDelegate.swift in Sources */, - 513CCF2524880C1500C55709 /* MasterFeedTableViewIdentifier.swift in Sources */, 51D6A5BC23199C85001C27D8 /* MasterTimelineDataSource.swift in Sources */, 51934CCB230F599B006127BE /* InteractiveNavigationController.swift in Sources */, 769F2ED513DA03EE75B993A8 /* NewsBlurAccountViewController.swift in Sources */, diff --git a/Shared/SmartFeeds/SmartFeed.swift b/Shared/SmartFeeds/SmartFeed.swift index e8aa2f1bc..e8245a0b9 100644 --- a/Shared/SmartFeeds/SmartFeed.swift +++ b/Shared/SmartFeeds/SmartFeed.swift @@ -14,6 +14,8 @@ import Account final class SmartFeed: PseudoFeed { + var account: Account? = nil + public var defaultReadFilterType: ReadFilterType { return .none } diff --git a/Shared/SmartFeeds/UnreadFeed.swift b/Shared/SmartFeeds/UnreadFeed.swift index f8ce3660c..eb9f4fb9c 100644 --- a/Shared/SmartFeeds/UnreadFeed.swift +++ b/Shared/SmartFeeds/UnreadFeed.swift @@ -19,6 +19,8 @@ import ArticlesDatabase // This just shows the global unread count, which appDelegate already has. Easy. final class UnreadFeed: PseudoFeed { + + var account: Account? = nil public var defaultReadFilterType: ReadFilterType { return .alwaysRead diff --git a/iOS/MasterFeed/Cell/MasterFeedRowIdentifier.swift b/iOS/MasterFeed/Cell/MasterFeedRowIdentifier.swift new file mode 100644 index 000000000..8737decf3 --- /dev/null +++ b/iOS/MasterFeed/Cell/MasterFeedRowIdentifier.swift @@ -0,0 +1,23 @@ +// +// MasterFeedRowIdentifier.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 10/20/21. +// Copyright © 2021 Ranchero Software. All rights reserved. +// + +import Foundation + +class MasterFeedRowIdentifier: NSObject, NSCopying { + + var indexPath: IndexPath + + init(indexPath: IndexPath) { + self.indexPath = indexPath + } + + func copy(with zone: NSZone? = nil) -> Any { + return self + } + +} diff --git a/iOS/MasterFeed/MasterFeedDataSource.swift b/iOS/MasterFeed/MasterFeedDataSource.swift deleted file mode 100644 index dff3d79c1..000000000 --- a/iOS/MasterFeed/MasterFeedDataSource.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// MasterFeedDataSource.swift -// NetNewsWire-iOS -// -// Created by Maurice Parker on 8/28/19. -// Copyright © 2019 Ranchero Software. All rights reserved. -// - -import UIKit -import RSTree -import Account - -class MasterFeedDataSource: UITableViewDiffableDataSource { - - override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - guard let identifier = itemIdentifier(for: indexPath), identifier.isEditable else { - return false - } - return true - } - -} diff --git a/iOS/MasterFeed/MasterFeedDataSourceOperation.swift b/iOS/MasterFeed/MasterFeedDataSourceOperation.swift deleted file mode 100644 index ac04c1946..000000000 --- a/iOS/MasterFeed/MasterFeedDataSourceOperation.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// MasterFeedDataSourceOperation.swift -// NetNewsWire-iOS -// -// Created by Maurice Parker on 2/23/20. -// Copyright © 2020 Ranchero Software. All rights reserved. -// - -import UIKit -import RSCore -import RSTree - -class MasterFeedDataSourceOperation: MainThreadOperation { - - // MainThreadOperation - public var isCanceled = false - public var id: Int? - public weak var operationDelegate: MainThreadOperationDelegate? - public var name: String? = "MasterFeedDataSourceOperation" - public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock? - - private var dataSource: UITableViewDiffableDataSource - private var snapshot: NSDiffableDataSourceSnapshot - private var animating: Bool - - init(dataSource: UITableViewDiffableDataSource, snapshot: NSDiffableDataSourceSnapshot, animating: Bool) { - self.dataSource = dataSource - self.snapshot = snapshot - self.animating = animating - } - - func run() { - dataSource.apply(snapshot, animatingDifferences: animating) { [weak self] in - guard let self = self else { return } - self.operationDelegate?.operationDidComplete(self) - } - } - -} diff --git a/iOS/MasterFeed/MasterFeedViewController+Drag.swift b/iOS/MasterFeed/MasterFeedViewController+Drag.swift index 7c089b796..9bf7a5010 100644 --- a/iOS/MasterFeed/MasterFeedViewController+Drag.swift +++ b/iOS/MasterFeed/MasterFeedViewController+Drag.swift @@ -13,11 +13,11 @@ import Account extension MasterFeedViewController: UITableViewDragDelegate { func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { - guard let identifier = dataSource.itemIdentifier(for: indexPath), identifier.isWebFeed, let url = identifier.url else { + guard let node = coordinator.nodeFor(indexPath), let webFeed = node.representedObject as? WebFeed else { return [UIDragItem]() } - let data = url.data(using: .utf8) + let data = webFeed.url.data(using: .utf8) let itemProvider = NSItemProvider() itemProvider.registerDataRepresentation(forTypeIdentifier: kUTTypeURL as String, visibility: .ownProcess) { completion in @@ -26,7 +26,7 @@ extension MasterFeedViewController: UITableViewDragDelegate { } let dragItem = UIDragItem(itemProvider: itemProvider) - dragItem.localObject = identifier + dragItem.localObject = node return [dragItem] } diff --git a/iOS/MasterFeed/MasterFeedViewController+Drop.swift b/iOS/MasterFeed/MasterFeedViewController+Drop.swift index 1dbb67fd2..c7f925c07 100644 --- a/iOS/MasterFeed/MasterFeedViewController+Drop.swift +++ b/iOS/MasterFeed/MasterFeedViewController+Drop.swift @@ -22,24 +22,22 @@ extension MasterFeedViewController: UITableViewDropDelegate { return UITableViewDropProposal(operation: .forbidden) } - guard let destIdentifier = dataSource.itemIdentifier(for: destIndexPath) else { - return UITableViewDropProposal(operation: .move, intent: .insertIntoDestinationIndexPath) - } - - guard let destAccount = destIdentifier.account, let destCell = tableView.cellForRow(at: destIndexPath) else { - return UITableViewDropProposal(operation: .forbidden) - } + guard let destFeed = coordinator.nodeFor(destIndexPath)?.representedObject as? Feed, + let destAccount = destFeed.account, + let destCell = tableView.cellForRow(at: destIndexPath) else { + return UITableViewDropProposal(operation: .forbidden) + } // Validate account specific behaviors... if destAccount.behaviors.contains(.disallowFeedInMultipleFolders), - let sourceFeedID = (session.localDragSession?.items.first?.localObject as? MasterFeedTableViewIdentifier)?.feedID, - let sourceWebFeed = AccountManager.shared.existingFeed(with: sourceFeedID) as? WebFeed, + let sourceNode = session.localDragSession?.items.first?.localObject as? Node, + let sourceWebFeed = sourceNode.representedObject as? WebFeed, sourceWebFeed.account?.accountID != destAccount.accountID && destAccount.hasWebFeed(withURL: sourceWebFeed.url) { return UITableViewDropProposal(operation: .forbidden) } // Determine the correct drop proposal - if destIdentifier.isFolder { + if destFeed is Folder { if session.location(in: destCell).y >= 0 { return UITableViewDropProposal(operation: .move, intent: .insertIntoDestinationIndexPath) } else { @@ -53,30 +51,29 @@ extension MasterFeedViewController: UITableViewDropDelegate { func tableView(_ tableView: UITableView, performDropWith dropCoordinator: UITableViewDropCoordinator) { guard let dragItem = dropCoordinator.items.first?.dragItem, - let sourceIdentifier = dragItem.localObject as? MasterFeedTableViewIdentifier, - let sourceParentContainerID = sourceIdentifier.parentContainerID, - let source = AccountManager.shared.existingContainer(with: sourceParentContainerID), - let destIndexPath = dropCoordinator.destinationIndexPath else { - return - } + let dragNode = dragItem.localObject as? Node, + let source = dragNode.parent?.representedObject as? Container, + let destIndexPath = dropCoordinator.destinationIndexPath else { + return + } let isFolderDrop: Bool = { - if let propDestIdentifier = dataSource.itemIdentifier(for: destIndexPath), let propCell = tableView.cellForRow(at: destIndexPath) { - return propDestIdentifier.isFolder && dropCoordinator.session.location(in: propCell).y >= 0 + if coordinator.nodeFor(destIndexPath)?.representedObject is Folder, let propCell = tableView.cellForRow(at: destIndexPath) { + return dropCoordinator.session.location(in: propCell).y >= 0 } return false }() // Based on the drop we have to determine a node to start looking for a parent container. - let destIdentifier: MasterFeedTableViewIdentifier? = { + let destNode: Node? = { if isFolderDrop { - return dataSource.itemIdentifier(for: destIndexPath) + return coordinator.nodeFor(destIndexPath) } else { if destIndexPath.row == 0 { - return dataSource.itemIdentifier(for: IndexPath(row: 0, section: destIndexPath.section)) + return coordinator.nodeFor(IndexPath(row: 0, section: destIndexPath.section)) } else if destIndexPath.row > 0 { - return dataSource.itemIdentifier(for: IndexPath(row: destIndexPath.row - 1, section: destIndexPath.section)) + return coordinator.nodeFor(IndexPath(row: destIndexPath.row - 1, section: destIndexPath.section)) } else { return nil } @@ -86,25 +83,21 @@ extension MasterFeedViewController: UITableViewDropDelegate { // Now we start looking for the parent container let destinationContainer: Container? = { - if let containerID = destIdentifier?.containerID ?? destIdentifier?.parentContainerID { - return AccountManager.shared.existingContainer(with: containerID) + if let container = (destNode?.representedObject as? Container) ?? (destNode?.parent?.representedObject as? Container) { + return container } else { // If we got here, we are trying to drop on an empty section header. Go and find the Account for this section return coordinator.rootNode.childAtIndex(destIndexPath.section)?.representedObject as? Account } }() - guard let destination = destinationContainer else { return } - guard case .webFeed(_, let webFeedID) = sourceIdentifier.feedID else { return } - guard let webFeed = source.existingWebFeed(withWebFeedID: webFeedID) else { return } + guard let destination = destinationContainer, let webFeed = dragNode.representedObject as? WebFeed else { return } if source.account == destination.account { moveWebFeedInAccount(feed: webFeed, sourceContainer: source, destinationContainer: destination) } else { moveWebFeedBetweenAccounts(feed: webFeed, sourceContainer: source, destinationContainer: destination) } - - } func moveWebFeedInAccount(feed: WebFeed, sourceContainer: Container, destinationContainer: Container) { diff --git a/iOS/MasterFeed/MasterFeedViewController.swift b/iOS/MasterFeed/MasterFeedViewController.swift index 4351326c1..b906b422c 100644 --- a/iOS/MasterFeed/MasterFeedViewController.swift +++ b/iOS/MasterFeed/MasterFeedViewController.swift @@ -27,9 +27,6 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { } } - private let operationQueue = MainThreadOperationQueue() - lazy var dataSource = makeDataSource() - var undoableCommands = [UndoableCommand]() weak var coordinator: SceneCoordinator! @@ -63,7 +60,6 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { tableView.tableHeaderView = UIView(frame: frame) tableView.register(MasterFeedTableViewSectionHeader.self, forHeaderFooterViewReuseIdentifier: "SectionHeader") - tableView.dataSource = dataSource tableView.dragDelegate = self tableView.dropDelegate = self tableView.dragInteractionEnabled = true @@ -83,7 +79,6 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { configureToolbar() becomeFirstResponder() - } override func viewWillAppear(_ animated: Bool) { @@ -94,7 +89,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { IconImageCache.shared.emptyCache() super.traitCollectionDidChange(previousTraitCollection) - reloadAllVisibleCells() +// reloadAllVisibleCells() } // MARK: Notifications @@ -123,11 +118,8 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { node = coordinator.rootNode.descendantNodeRepresentingObject(representedObject as AnyObject) } - guard let unreadCountNode = node else { return } - let identifier = makeIdentifier(unreadCountNode) - if dataSource.indexPath(for: identifier) != nil { - self.reload(identifier) - } + guard let unreadCountNode = node, let indexPath = coordinator.indexPathFor(unreadCountNode) else { return } + tableView.reloadRows(at: [indexPath], with: .middle) } @objc func faviconDidBecomeAvailable(_ note: Notification) { @@ -152,7 +144,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { @objc func contentSizeCategoryDidChange(_ note: Notification) { resetEstimatedRowHeight() - applyChanges(animated: false) + tableView.reloadData() } @objc func willEnterForeground(_ note: Notification) { @@ -161,6 +153,20 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { // MARK: Table View + override func numberOfSections(in tableView: UITableView) -> Int { + coordinator.numberOfSections() + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + coordinator.numberOfRows(in: section) + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! MasterFeedTableViewCell + configure(cell, indexPath) + return cell + } + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { guard let nameProvider = coordinator.rootNode.childAtIndex(section)?.representedObject as? DisplayNameProvider else { @@ -251,13 +257,13 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { renameAction.backgroundColor = UIColor.systemOrange actions.append(renameAction) - if let identifier = dataSource.itemIdentifier(for: indexPath), identifier.isWebFeed { + if let webFeed = coordinator.nodeFor(indexPath)?.representedObject as? WebFeed { let moreTitle = NSLocalizedString("More", comment: "More") let moreAction = UIContextualAction(style: .normal, title: moreTitle) { [weak self] (action, view, completion) in if let self = self { - let alert = UIAlertController(title: identifier.nameForDisplay, message: nil, preferredStyle: .actionSheet) + let alert = UIAlertController(title: webFeed.nameForDisplay, message: nil, preferredStyle: .actionSheet) if let popoverController = alert.popoverPresentationController { popoverController.sourceView = view popoverController.sourceRect = CGRect(x: view.frame.size.width/2, y: view.frame.size.height/2, width: 1, height: 1) @@ -303,26 +309,25 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { } override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - guard let identifier = dataSource.itemIdentifier(for: indexPath) else { + guard let feed = coordinator.nodeFor(indexPath)?.representedObject as? Feed else { return nil } - if identifier.isWebFeed { - return makeWebFeedContextMenu(identifier: identifier, indexPath: indexPath, includeDeleteRename: true) - } else if identifier.isFolder { - return makeFolderContextMenu(identifier: identifier, indexPath: indexPath) - } else if identifier.isPsuedoFeed { - return makePseudoFeedContextMenu(identifier: identifier, indexPath: indexPath) + if feed is WebFeed { + return makeWebFeedContextMenu(indexPath: indexPath, includeDeleteRename: true) + } else if feed is Folder { + return makeFolderContextMenu(indexPath: indexPath) + } else if feed is PseudoFeed { + return makePseudoFeedContextMenu(indexPath: indexPath) } else { return nil } } override func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - guard let identifier = configuration.identifier as? MasterFeedTableViewIdentifier, - let indexPath = dataSource.indexPath(for: identifier), - let cell = tableView.cellForRow(at: indexPath) else { - return nil - } + guard let identifier = configuration.identifier as? MasterFeedRowIdentifier, + let cell = tableView.cellForRow(at: identifier.indexPath) else { + return nil + } return UITargetedPreview(view: cell, parameters: CroppingPreviewParameters(view: cell)) } @@ -342,21 +347,16 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { return coordinator.cappedIndexPath(proposedDestinationIndexPath) }() - guard let draggedIdentifier = dataSource.itemIdentifier(for: sourceIndexPath), - let draggedFeedID = draggedIdentifier.feedID, - let draggedNode = coordinator.nodeFor(feedID: draggedFeedID) else { + guard let draggedNode = coordinator.nodeFor(sourceIndexPath) else { assertionFailure("This should never happen") return sourceIndexPath } // If there is no destination node, we are dragging onto an empty Account - guard let destIdentifier = dataSource.itemIdentifier(for: destIndexPath), - let destFeedID = destIdentifier.feedID, - let destNode = coordinator.nodeFor(feedID: destFeedID), - let destParentContainerID = destIdentifier.parentContainerID, - let destParentNode = coordinator.nodeFor(containerID: destParentContainerID) else { - return proposedDestinationIndexPath - } + guard let destNode = coordinator.nodeFor(destIndexPath), + let destParentNode = destNode.parent else { + return proposedDestinationIndexPath + } // If this is a folder, let the users drop on it if destNode.representedObject is Folder { @@ -381,8 +381,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { if destParentNode.representedObject is Account { return IndexPath(row: 0, section: destIndexPath.section) } else { - let identifier = makeIdentifier(sortedNodes[index]) - if let candidateIndexPath = dataSource.indexPath(for: identifier) { + if let candidateIndexPath = coordinator.indexPathFor(sortedNodes[index]) { let movementAdjustment = sourceIndexPath < destIndexPath ? 1 : 0 return IndexPath(row: candidateIndexPath.row - movementAdjustment, section: candidateIndexPath.section) } else { @@ -393,8 +392,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { } else { if index >= sortedNodes.count { - let identifier = makeIdentifier(sortedNodes[sortedNodes.count - 1]) - if let lastSortedIndexPath = dataSource.indexPath(for: identifier) { + if let lastSortedIndexPath = coordinator.indexPathFor(sortedNodes[sortedNodes.count - 1]) { let movementAdjustment = sourceIndexPath > destIndexPath ? 1 : 0 return IndexPath(row: lastSortedIndexPath.row + movementAdjustment, section: lastSortedIndexPath.section) } else { @@ -402,8 +400,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { } } else { let movementAdjustment = sourceIndexPath < destIndexPath ? 1 : 0 - let identifer = makeIdentifier(sortedNodes[index - movementAdjustment]) - return dataSource.indexPath(for: identifer) ?? sourceIndexPath + return coordinator.indexPathFor(sortedNodes[index - movementAdjustment]) ?? sourceIndexPath } } @@ -515,14 +512,14 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { } @objc func expandSelectedRows(_ sender: Any?) { - if let indexPath = coordinator.currentFeedIndexPath, let containerID = dataSource.itemIdentifier(for: indexPath)?.containerID { - coordinator.expand(containerID) + if let indexPath = coordinator.currentFeedIndexPath, let node = coordinator.nodeFor(indexPath) { + coordinator.expand(node) } } @objc func collapseSelectedRows(_ sender: Any?) { - if let indexPath = coordinator.currentFeedIndexPath, let containerID = dataSource.itemIdentifier(for: indexPath)?.containerID { - coordinator.collapse(containerID) + if let indexPath = coordinator.currentFeedIndexPath, let node = coordinator.nodeFor(indexPath) { + coordinator.collapse(node) } } @@ -562,23 +559,54 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { } func updateFeedSelection(animations: Animations) { - operationQueue.add(UpdateSelectionOperation(coordinator: coordinator, dataSource: dataSource, tableView: tableView, animations: animations)) - } - - func reloadFeeds(initialLoad: Bool, completion: (() -> Void)? = nil) { - updateUI() - - // We have to reload all the visible cells because if we got here by doing a table cell move, - // then the table itself is in a weird state. This is because we do unusual things like allowing - // drops on a "folder" that should cause the dropped cell to disappear. - applyChanges(animated: !initialLoad) { [weak self] in - if !initialLoad { - self?.reloadAllVisibleCells(completion: completion) - } else { - completion?() + if let indexPath = coordinator.currentFeedIndexPath { + tableView.selectRowAndScrollIfNotVisible(at: indexPath, animations: animations) + } else { + if let indexPath = tableView.indexPathForSelectedRow { + if animations.contains(.select) { + tableView.deselectRow(at: indexPath, animated: true) + } else { + tableView.deselectRow(at: indexPath, animated: false) + } } } } + + func reloadFeeds(initialLoad: Bool, changes: [ShadowTableChanges], completion: (() -> Void)? = nil) { + updateUI() + + guard !initialLoad else { + tableView.reloadData() + completion?() + return + } + + for change in changes { + guard !change.isEmpty else { continue } + + tableView.performBatchUpdates { + if let deletes = change.deleteIndexPaths, !deletes.isEmpty { + tableView.deleteRows(at: deletes, with: .middle) + } + + if let inserts = change.insertIndexPaths, !inserts.isEmpty { + tableView.insertRows(at: inserts, with: .middle) + } + + if let moves = change.moveIndexPaths, !moves.isEmpty { + for move in moves { + tableView.moveRow(at: move.0, to: move.1) + } + } + + if let reloads = change.reloadIndexPaths, !reloads.isEmpty { + tableView.reloadRows(at: reloads, with: .middle) + } + } + } + + completion?() + } func updateUI() { if coordinator.isReadFeedsFiltered { @@ -741,65 +769,6 @@ private extension MasterFeedViewController { filterButton?.accLabelText = NSLocalizedString("Filter Read Feeds", comment: "Filter Read Feeds") } - func makeIdentifier(_ node: Node) -> MasterFeedTableViewIdentifier { - let unreadCount = coordinator.unreadCountFor(node) - return MasterFeedTableViewIdentifier(node: node, unreadCount: unreadCount) - } - - func reload(_ identifier: MasterFeedTableViewIdentifier) { - var snapshot = dataSource.snapshot() - snapshot.reloadItems([identifier]) - queueApply(snapshot: snapshot, animatingDifferences: false) { [weak self] in - self?.restoreSelectionIfNecessary(adjustScroll: false) - } - } - - func applyChanges(animated: Bool, adjustScroll: Bool = false, completion: (() -> Void)? = nil) { - var snapshot = NSDiffableDataSourceSnapshot() - let sectionIdentifiers = Array(0...coordinator.rootNode.childNodes.count - 1) - snapshot.appendSections(sectionIdentifiers) - - for sectionIdentifer in sectionIdentifiers { - let identifiers = coordinator.shadowNodesFor(section: sectionIdentifer).map { makeIdentifier($0) } - snapshot.appendItems(identifiers, toSection: sectionIdentifer) - } - - queueApply(snapshot: snapshot, animatingDifferences: animated) { [weak self] in - self?.restoreSelectionIfNecessary(adjustScroll: adjustScroll) - completion?() - } - } - - func queueApply(snapshot: NSDiffableDataSourceSnapshot, animatingDifferences: Bool = true, completion: (() -> Void)? = nil) { - let operation = MasterFeedDataSourceOperation(dataSource: dataSource, snapshot: snapshot, animating: animatingDifferences) - operation.completionBlock = { [weak self] _ in - self?.enableTableViewSelection() - completion?() - } - disableTableViewSelectionIfNecessary() - operationQueue.add(operation) - } - - private func disableTableViewSelectionIfNecessary() { - // We only need to disable tableView selection if the feeds are filtered by unread - guard coordinator.isReadFeedsFiltered else { return } - tableView.allowsSelection = false - } - - private func enableTableViewSelection() { - tableView.allowsSelection = true - } - - func makeDataSource() -> MasterFeedDataSource { - let dataSource = MasterFeedDataSource(tableView: tableView, cellProvider: { [weak self] tableView, indexPath, cellContents in - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! MasterFeedTableViewCell - self?.configure(cell, cellContents) - return cell - }) - dataSource.defaultRowAnimation = .middle - return dataSource - } - func resetEstimatedRowHeight() { let titleLabel = NonIntrinsicLabel() titleLabel.text = "But I must explain" @@ -811,27 +780,30 @@ private extension MasterFeedViewController { tableView.estimatedRowHeight = layout.height } - func configure(_ cell: MasterFeedTableViewCell, _ identifier: MasterFeedTableViewIdentifier) { - + func configure(_ cell: MasterFeedTableViewCell, _ indexPath: IndexPath) { + guard let node = coordinator.nodeFor(indexPath) else { return } + cell.delegate = self - if identifier.isFolder { + if node.representedObject is Folder { cell.indentationLevel = 0 } else { cell.indentationLevel = 1 } - if let containerID = identifier.containerID { + if let containerID = (node.representedObject as? Container)?.containerID { cell.setDisclosure(isExpanded: coordinator.isExpanded(containerID), animated: false) cell.isDisclosureAvailable = true } else { cell.isDisclosureAvailable = false } - cell.name = identifier.nameForDisplay - cell.unreadCount = identifier.unreadCount - configureIcon(cell, identifier) - - guard let indexPath = dataSource.indexPath(for: identifier) else { return } + if let feed = node.representedObject as? Feed { + cell.name = feed.nameForDisplay + cell.unreadCount = feed.unreadCount + } + + configureIcon(cell, indexPath) + let rowsInSection = tableView.numberOfRows(inSection: indexPath.section) if indexPath.row == rowsInSection - 1 { cell.isSeparatorShown = false @@ -841,8 +813,8 @@ private extension MasterFeedViewController { } - func configureIcon(_ cell: MasterFeedTableViewCell, _ identifier: MasterFeedTableViewIdentifier) { - guard let feedID = identifier.feedID else { + func configureIcon(_ cell: MasterFeedTableViewCell, _ indexPath: IndexPath) { + guard let node = coordinator.nodeFor(indexPath), let feed = node.representedObject as? Feed, let feedID = feed.feedID else { return } cell.iconImage = IconImageCache.shared.imageFor(feedID) @@ -859,35 +831,29 @@ private extension MasterFeedViewController { applyToCellsForRepresentedObject(representedObject, configure) } - func applyToCellsForRepresentedObject(_ representedObject: AnyObject, _ completion: (MasterFeedTableViewCell, MasterFeedTableViewIdentifier) -> Void) { - applyToAvailableCells { (cell, identifier) in - if let representedFeed = representedObject as? Feed, representedFeed.feedID == identifier.feedID { - completion(cell, identifier) + func applyToCellsForRepresentedObject(_ representedObject: AnyObject, _ completion: (MasterFeedTableViewCell, IndexPath) -> Void) { + applyToAvailableCells { (cell, indexPath) in + if let node = coordinator.nodeFor(indexPath), + let representedFeed = representedObject as? Feed, + let candidate = node.representedObject as? Feed, + representedFeed.feedID == candidate.feedID { + completion(cell, indexPath) } } } - func applyToAvailableCells(_ completion: (MasterFeedTableViewCell, MasterFeedTableViewIdentifier) -> Void) { + func applyToAvailableCells(_ completion: (MasterFeedTableViewCell, IndexPath) -> Void) { tableView.visibleCells.forEach { cell in - guard let indexPath = tableView.indexPath(for: cell), let identifier = dataSource.itemIdentifier(for: indexPath) else { + guard let indexPath = tableView.indexPath(for: cell) else { return } - completion(cell as! MasterFeedTableViewCell, identifier) + completion(cell as! MasterFeedTableViewCell, indexPath) } } private func reloadAllVisibleCells(completion: (() -> Void)? = nil) { - let visibleNodes = tableView.indexPathsForVisibleRows!.compactMap { return dataSource.itemIdentifier(for: $0) } - reloadCells(visibleNodes, completion: completion) - } - - private func reloadCells(_ identifiers: [MasterFeedTableViewIdentifier], completion: (() -> Void)? = nil) { - var snapshot = dataSource.snapshot() - snapshot.reloadItems(identifiers) - queueApply(snapshot: snapshot, animatingDifferences: false) { [weak self] in - self?.restoreSelectionIfNecessary(adjustScroll: false) - completion?() - } + guard let indexPaths = tableView.indexPathsForVisibleRows else { return } + tableView.reloadRows(at: indexPaths, with: .middle) } private func accountForNode(_ node: Node) -> Account? { @@ -918,21 +884,21 @@ private extension MasterFeedViewController { } func expand(_ cell: MasterFeedTableViewCell) { - guard let indexPath = tableView.indexPath(for: cell), let containerID = dataSource.itemIdentifier(for: indexPath)?.containerID else { + guard let indexPath = tableView.indexPath(for: cell), let node = coordinator.nodeFor(indexPath) else { return } - coordinator.expand(containerID) + coordinator.expand(node) } func collapse(_ cell: MasterFeedTableViewCell) { - guard let indexPath = tableView.indexPath(for: cell), let containerID = dataSource.itemIdentifier(for: indexPath)?.containerID else { + guard let indexPath = tableView.indexPath(for: cell), let node = coordinator.nodeFor(indexPath) else { return } - coordinator.collapse(containerID) + coordinator.collapse(node) } - func makeWebFeedContextMenu(identifier: MasterFeedTableViewIdentifier, indexPath: IndexPath, includeDeleteRename: Bool) -> UIContextMenuConfiguration { - return UIContextMenuConfiguration(identifier: identifier as NSCopying, previewProvider: nil, actionProvider: { [ weak self] suggestedActions in + func makeWebFeedContextMenu(indexPath: IndexPath, includeDeleteRename: Bool) -> UIContextMenuConfiguration { + return UIContextMenuConfiguration(identifier: MasterFeedRowIdentifier(indexPath: indexPath), previewProvider: nil, actionProvider: { [ weak self] suggestedActions in guard let self = self else { return nil } @@ -976,8 +942,8 @@ private extension MasterFeedViewController { } - func makeFolderContextMenu(identifier: MasterFeedTableViewIdentifier, indexPath: IndexPath) -> UIContextMenuConfiguration { - return UIContextMenuConfiguration(identifier: identifier as NSCopying, previewProvider: nil, actionProvider: { [weak self] suggestedActions in + func makeFolderContextMenu(indexPath: IndexPath) -> UIContextMenuConfiguration { + return UIContextMenuConfiguration(identifier: MasterFeedRowIdentifier(indexPath: indexPath), previewProvider: nil, actionProvider: { [weak self] suggestedActions in guard let self = self else { return nil } @@ -999,12 +965,12 @@ private extension MasterFeedViewController { }) } - func makePseudoFeedContextMenu(identifier: MasterFeedTableViewIdentifier, indexPath: IndexPath) -> UIContextMenuConfiguration? { + func makePseudoFeedContextMenu(indexPath: IndexPath) -> UIContextMenuConfiguration? { guard let markAllAction = self.markAllAsReadAction(indexPath: indexPath) else { return nil } - return UIContextMenuConfiguration(identifier: identifier as NSCopying, previewProvider: nil, actionProvider: { suggestedActions in + return UIContextMenuConfiguration(identifier: MasterFeedRowIdentifier(indexPath: indexPath), previewProvider: nil, actionProvider: { suggestedActions in return UIMenu(title: "", children: [markAllAction]) }) } @@ -1035,11 +1001,10 @@ private extension MasterFeedViewController { } func copyFeedPageAction(indexPath: IndexPath) -> UIAction? { - guard let feedID = dataSource.itemIdentifier(for: indexPath)?.feedID, - let webFeed = AccountManager.shared.existingFeed(with: feedID) as? WebFeed, - let url = URL(string: webFeed.url) else { - return nil - } + guard let webFeed = coordinator.nodeFor(indexPath)?.representedObject as? WebFeed, + let url = URL(string: webFeed.url) else { + return nil + } let title = NSLocalizedString("Copy Feed URL", comment: "Copy Feed URL") let action = UIAction(title: title, image: AppAssets.copyImage) { action in @@ -1049,12 +1014,11 @@ private extension MasterFeedViewController { } func copyFeedPageAlertAction(indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? { - guard let feedID = dataSource.itemIdentifier(for: indexPath)?.feedID, - let webFeed = AccountManager.shared.existingFeed(with: feedID) as? WebFeed, - let url = URL(string: webFeed.url) else { - return nil - } - + guard let webFeed = coordinator.nodeFor(indexPath)?.representedObject as? WebFeed, + let url = URL(string: webFeed.url) else { + return nil + } + let title = NSLocalizedString("Copy Feed URL", comment: "Copy Feed URL") let action = UIAlertAction(title: title, style: .default) { action in UIPasteboard.general.url = url @@ -1064,12 +1028,11 @@ private extension MasterFeedViewController { } func copyHomePageAction(indexPath: IndexPath) -> UIAction? { - guard let feedID = dataSource.itemIdentifier(for: indexPath)?.feedID, - let webFeed = AccountManager.shared.existingFeed(with: feedID) as? WebFeed, - let homePageURL = webFeed.homePageURL, - let url = URL(string: homePageURL) else { - return nil - } + guard let webFeed = coordinator.nodeFor(indexPath)?.representedObject as? WebFeed, + let homePageURL = webFeed.homePageURL, + let url = URL(string: homePageURL) else { + return nil + } let title = NSLocalizedString("Copy Home Page URL", comment: "Copy Home Page URL") let action = UIAction(title: title, image: AppAssets.copyImage) { action in @@ -1079,13 +1042,12 @@ private extension MasterFeedViewController { } func copyHomePageAlertAction(indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? { - guard let feedID = dataSource.itemIdentifier(for: indexPath)?.feedID, - let webFeed = AccountManager.shared.existingFeed(with: feedID) as? WebFeed, - let homePageURL = webFeed.homePageURL, - let url = URL(string: homePageURL) else { - return nil - } - + guard let webFeed = coordinator.nodeFor(indexPath)?.representedObject as? WebFeed, + let homePageURL = webFeed.homePageURL, + let url = URL(string: homePageURL) else { + return nil + } + let title = NSLocalizedString("Copy Home Page URL", comment: "Copy Home Page URL") let action = UIAlertAction(title: title, style: .default) { action in UIPasteboard.general.url = url @@ -1095,16 +1057,14 @@ private extension MasterFeedViewController { } func markAllAsReadAlertAction(indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? { - guard let identifier = dataSource.itemIdentifier(for: indexPath), - identifier.unreadCount > 0, - let feedID = identifier.feedID, - let feed = AccountManager.shared.existingFeed(with: feedID) as? WebFeed, - let articles = try? feed.fetchArticles(), let contentView = self.tableView.cellForRow(at: indexPath)?.contentView else { + guard let webFeed = coordinator.nodeFor(indexPath)?.representedObject as? WebFeed, + webFeed.unreadCount > 0, + let articles = try? webFeed.fetchArticles(), let contentView = self.tableView.cellForRow(at: indexPath)?.contentView else { return nil } let localizedMenuText = NSLocalizedString("Mark All as Read in “%@”", comment: "Command") - let title = NSString.localizedStringWithFormat(localizedMenuText as NSString, feed.nameForDisplay) as String + let title = NSString.localizedStringWithFormat(localizedMenuText as NSString, webFeed.nameForDisplay) as String let cancel = { completion(true) } @@ -1137,13 +1097,13 @@ private extension MasterFeedViewController { } func getInfoAction(indexPath: IndexPath) -> UIAction? { - guard let feedID = dataSource.itemIdentifier(for: indexPath)?.feedID, let feed = AccountManager.shared.existingFeed(with: feedID) as? WebFeed else { + guard let webFeed = coordinator.nodeFor(indexPath)?.representedObject as? WebFeed else { return nil } let title = NSLocalizedString("Get Info", comment: "Get Info") let action = UIAction(title: title, image: AppAssets.infoImage) { [weak self] action in - self?.coordinator.showFeedInspector(for: feed) + self?.coordinator.showFeedInspector(for: webFeed) } return action } @@ -1165,41 +1125,25 @@ private extension MasterFeedViewController { } func getInfoAlertAction(indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? { - guard let feedID = dataSource.itemIdentifier(for: indexPath)?.feedID, let feed = AccountManager.shared.existingFeed(with: feedID) as? WebFeed else { + guard let webFeed = coordinator.nodeFor(indexPath)?.representedObject as? WebFeed else { return nil } let title = NSLocalizedString("Get Info", comment: "Get Info") let action = UIAlertAction(title: title, style: .default) { [weak self] action in - self?.coordinator.showFeedInspector(for: feed) + self?.coordinator.showFeedInspector(for: webFeed) completion(true) } return action } func markAllAsReadAction(indexPath: IndexPath) -> UIAction? { - guard let identifier = dataSource.itemIdentifier(for: indexPath), identifier.unreadCount > 0 else { - return nil - } + guard let feed = coordinator.nodeFor(indexPath)?.representedObject as? Feed, + let contentView = self.tableView.cellForRow(at: indexPath)?.contentView, + feed.unreadCount > 0 else { + return nil + } - var smartFeed: Feed? - if identifier.isPsuedoFeed { - if SmartFeedsController.shared.todayFeed.feedID == identifier.feedID { - smartFeed = SmartFeedsController.shared.todayFeed - } else if SmartFeedsController.shared.unreadFeed.feedID == identifier.feedID { - smartFeed = SmartFeedsController.shared.unreadFeed - } else if SmartFeedsController.shared.starredFeed.feedID == identifier.feedID { - smartFeed = SmartFeedsController.shared.starredFeed - } - } - - guard let feedID = identifier.feedID, - let feed = smartFeed ?? AccountManager.shared.existingFeed(with: feedID), - feed.unreadCount > 0, - let contentView = self.tableView.cellForRow(at: indexPath)?.contentView else { - return nil - } - let localizedMenuText = NSLocalizedString("Mark All as Read in “%@”", comment: "Command") let title = NSString.localizedStringWithFormat(localizedMenuText as NSString, feed.nameForDisplay) as String let action = UIAction(title: title, image: AppAssets.markAllAsReadImage) { [weak self] action in @@ -1236,11 +1180,10 @@ private extension MasterFeedViewController { func rename(indexPath: IndexPath) { - guard let feedID = dataSource.itemIdentifier(for: indexPath)?.feedID, let feed = AccountManager.shared.existingFeed(with: feedID) else { return } + guard let feed = coordinator.nodeFor(indexPath)?.representedObject as? Feed else { return } - let name = dataSource.itemIdentifier(for: indexPath)?.nameForDisplay ?? "" let formatString = NSLocalizedString("Rename “%@”", comment: "Rename feed") - let title = NSString.localizedStringWithFormat(formatString as NSString, name) as String + let title = NSString.localizedStringWithFormat(formatString as NSString, feed.nameForDisplay) as String let alertController = UIAlertController(title: title, message: nil, preferredStyle: .alert) @@ -1280,7 +1223,7 @@ private extension MasterFeedViewController { alertController.preferredAction = renameAction alertController.addTextField() { textField in - textField.text = name + textField.text = feed.nameForDisplay textField.placeholder = NSLocalizedString("Name", comment: "Name") } @@ -1291,7 +1234,7 @@ private extension MasterFeedViewController { } func delete(indexPath: IndexPath) { - guard let feedID = dataSource.itemIdentifier(for: indexPath)?.feedID, let feed = AccountManager.shared.existingFeed(with: feedID) else { return } + guard let feed = coordinator.nodeFor(indexPath)?.representedObject as? Feed else { return } let title: String let message: String @@ -1312,7 +1255,7 @@ private extension MasterFeedViewController { let deleteTitle = NSLocalizedString("Delete", comment: "Delete") let deleteAction = UIAlertAction(title: deleteTitle, style: .destructive) { [weak self] action in - self?.delete(indexPath: indexPath, feedID: feedID) + self?.performDelete(indexPath: indexPath) } alertController.addAction(deleteAction) alertController.preferredAction = deleteAction @@ -1320,9 +1263,9 @@ private extension MasterFeedViewController { self.present(alertController, animated: true) } - func delete(indexPath: IndexPath, feedID: FeedIdentifier) { + func performDelete(indexPath: IndexPath) { guard let undoManager = undoManager, - let deleteNode = coordinator.nodeFor(feedID: feedID), + let deleteNode = coordinator.nodeFor(indexPath), let deleteCommand = DeleteCommand(nodesToDelete: [deleteNode], undoManager: undoManager, errorHandler: ErrorHandler.present(self)) else { return } diff --git a/iOS/MasterTimeline/MasterTimelineViewController.swift b/iOS/MasterTimeline/MasterTimelineViewController.swift index 4d4219c39..72a019828 100644 --- a/iOS/MasterTimeline/MasterTimelineViewController.swift +++ b/iOS/MasterTimeline/MasterTimelineViewController.swift @@ -688,7 +688,7 @@ private extension MasterTimelineViewController { func updateTitleUnreadCount() { if let titleView = navigationItem.titleView as? MasterTimelineTitleView { - titleView.unreadCountView.unreadCount = coordinator.unreadCount + titleView.unreadCountView.unreadCount = coordinator.timelineUnreadCount } } diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index f3a2c1abc..82250d964 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -19,6 +19,7 @@ enum PanelMode { case three case standard } + enum SearchScope: Int { case timeline = 0 case global = 1 @@ -30,7 +31,21 @@ enum ShowFeedName { case feed } -class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { +struct FeedNode: Hashable { + var node: Node + var feedID: FeedIdentifier + + init(_ node: Node) { + self.node = node + self.feedID = (node.representedObject as! Feed).feedID! + } + + func hash(into hasher: inout Hasher) { + hasher.combine(feedID) + } +} + +class SceneCoordinator: NSObject, UndoableCommandRunner { var undoableCommands = [UndoableCommand]() var undoManager: UndoManager? { @@ -74,7 +89,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { private var expandedTable = Set() private var readFilterEnabledTable = [FeedIdentifier: Bool]() - private var shadowTable = [[Node]]() + private var shadowTable = [[FeedNode]]() private(set) var preSearchTimelineFeed: Feed? private var lastSearchString = "" @@ -285,20 +300,14 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { } var isTimelineUnreadAvailable: Bool { - return unreadCount > 0 + return timelineUnreadCount > 0 } var isAnyUnreadAvailable: Bool { return appDelegate.unreadCount > 0 } - var unreadCount: Int = 0 { - didSet { - if unreadCount != oldValue { - postUnreadCountDidChangeNotification() - } - } - } + var timelineUnreadCount: Int = 0 override init() { treeController = TreeController(delegate: treeControllerDelegate) @@ -307,7 +316,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { for sectionNode in treeController.rootNode.childNodes { markExpanded(sectionNode) - shadowTable.append([Node]()) + shadowTable.append([FeedNode]()) } NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidInitialize(_:)), name: .UnreadCountDidInitialize, object: nil) @@ -650,20 +659,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { refreshTimeline(resetScroll: false) } - - func shadowNodesFor(section: Int) -> [Node] { - return shadowTable[section] - } - - func nodeFor(containerID: ContainerIdentifier) -> Node? { - return treeController.rootNode.descendantNode(where: { node in - if let container = node.representedObject as? Container { - return container.containerID == containerID - } else { - return false - } - }) - } func nodeFor(feedID: FeedIdentifier) -> Node? { return treeController.rootNode.descendantNode(where: { node in @@ -675,6 +670,30 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { }) } + func numberOfSections() -> Int { + return shadowTable.count + } + + func numberOfRows(in section: Int) -> Int { + return shadowTable[section].count + } + + func nodeFor(_ indexPath: IndexPath) -> Node? { + guard indexPath.section < shadowTable.count && indexPath.row < shadowTable[indexPath.section].count else { + return nil + } + return shadowTable[indexPath.section][indexPath.row].node + } + + func indexPathFor(_ node: Node) -> IndexPath? { + for i in 0.. Article? { return idToAticleDictionary[articleID] } @@ -689,7 +708,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { func unreadCountFor(_ node: Node) -> Int { // The coordinator supplies the unread count for the currently selected feed if node.representedObject === timelineFeed as AnyObject { - return unreadCount + return timelineUnreadCount } if let unreadCountProvider = node.representedObject as? UnreadCountProvider { return unreadCountProvider.unreadCount @@ -1430,7 +1449,7 @@ private extension SceneCoordinator { count += 1 } } - unreadCount = count + timelineUnreadCount = count } func rebuildArticleDictionaries() { @@ -1483,8 +1502,8 @@ private extension SceneCoordinator { func addShadowTableToFilterExceptions() { for section in shadowTable { - for node in section { - if let feed = node.representedObject as? Feed, let feedID = feed.feedID { + for feedNode in section { + if let feed = feedNode.node.representedObject as? Feed, let feedID = feed.feedID { treeControllerDelegate.addFilterException(feedID) } } @@ -1501,51 +1520,81 @@ private extension SceneCoordinator { func rebuildBackingStores(initialLoad: Bool = false, updateExpandedNodes: (() -> Void)? = nil, completion: (() -> Void)? = nil) { if !BatchUpdate.shared.isPerforming { - addToFilterExeptionsIfNecessary(timelineFeed) treeController.rebuild() treeControllerDelegate.resetFilterExceptions() updateExpandedNodes?() - rebuildShadowTable() - masterFeedViewController.reloadFeeds(initialLoad: initialLoad, completion: completion) - + let changes = rebuildShadowTable() + masterFeedViewController.reloadFeeds(initialLoad: initialLoad, changes: changes, completion: completion) } } - func rebuildShadowTable() { - shadowTable = [[Node]]() + func rebuildShadowTable() -> [ShadowTableChanges] { + var newShadowTable = [[FeedNode]]() for i in 0..() + var inserts = Set() + var deletes = Set() + + let diff = newSectionNodes.difference(from: shadowTable[index]).inferringMoves() + for change in diff { + switch change { + case .insert(let offset, _, let associated): + if let associated = associated { + moves.insert(ShadowTableChanges.Move(associated, offset)) + } else { + inserts.insert(offset) + } + case .remove(let offset, _, let associated): + if let associated = associated { + moves.insert(ShadowTableChanges.Move(offset, associated)) + } else { + deletes.insert(offset) + } + } + } + + changes.append(ShadowTableChanges(section: index, deletes: deletes, inserts: inserts, moves: moves)) + } + + shadowTable = newShadowTable + + return changes } func shadowTableContains(_ feed: Feed) -> Bool { for section in shadowTable { - for node in section { - if let nodeFeed = node.representedObject as? Feed, nodeFeed.feedID == feed.feedID { + for feedNode in section { + if let nodeFeed = feedNode.node.representedObject as? Feed, nodeFeed.feedID == feed.feedID { return true } } @@ -1559,22 +1608,6 @@ private extension SceneCoordinator { } } - func nodeFor(_ indexPath: IndexPath) -> Node? { - guard indexPath.section < shadowTable.count && indexPath.row < shadowTable[indexPath.section].count else { - return nil - } - return shadowTable[indexPath.section][indexPath.row] - } - - func indexPathFor(_ node: Node) -> IndexPath? { - for i in 0.. IndexPath? { guard let node = treeController.rootNode.descendantNodeRepresentingObject(object) else { return nil diff --git a/iOS/ShadowTableChanges.swift b/iOS/ShadowTableChanges.swift new file mode 100644 index 000000000..34f200afb --- /dev/null +++ b/iOS/ShadowTableChanges.swift @@ -0,0 +1,65 @@ +// +// ShadowTableChanges.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 10/20/21. +// Copyright © 2021 Ranchero Software. All rights reserved. +// + +import Foundation + +public struct ShadowTableChanges { + + public struct Move: Hashable { + public var from: Int + public var to: Int + + init(_ from: Int, _ to: Int) { + self.from = from + self.to = to + } + } + + public var section: Int + public var deletes: Set? + public var inserts: Set? + public var moves: Set? + public var reloads: Set? + + public var isEmpty: Bool { + return (deletes?.isEmpty ?? true) && (inserts?.isEmpty ?? true) && (moves?.isEmpty ?? true) && (reloads?.isEmpty ?? true) + } + + public var isOnlyReloads: Bool { + return (deletes?.isEmpty ?? true) && (inserts?.isEmpty ?? true) && (moves?.isEmpty ?? true) + } + + public var deleteIndexPaths: [IndexPath]? { + guard let deletes = deletes else { return nil } + return deletes.map { IndexPath(row: $0, section: section) } + } + + public var insertIndexPaths: [IndexPath]? { + guard let inserts = inserts else { return nil } + return inserts.map { IndexPath(row: $0, section: section) } + } + + public var moveIndexPaths: [(IndexPath, IndexPath)]? { + guard let moves = moves else { return nil } + return moves.map { (IndexPath(row: $0.from, section: section), IndexPath(row: $0.to, section: section)) } + } + + public var reloadIndexPaths: [IndexPath]? { + guard let reloads = reloads else { return nil } + return reloads.map { IndexPath(row: $0, section: section) } + } + + init(section: Int, deletes: Set? = nil, inserts: Set? = nil, moves: Set? = nil, reloads: Set? = nil) { + self.section = section + self.deletes = deletes + self.inserts = inserts + self.moves = moves + self.reloads = reloads + } + +}