diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 483ba78c1..10d84d58b 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -2071,6 +2071,7 @@ 51627A6823861DED007B3B4B /* MasterFeedViewController+Drop.swift */, 51CE1C0A23622006005548FC /* RefreshProgressView.swift */, 51CE1C0823621EDA005548FC /* RefreshProgressView.xib */, + 5195C1D92720205F00888867 /* ShadowTableChanges.swift */, 51C45260226508F600C03939 /* Cell */, ); path = MasterFeed; @@ -2691,7 +2692,6 @@ 840D617E2029031C009BC708 /* AppDelegate.swift */, 519E743422C663F900A78E47 /* SceneDelegate.swift */, 5126EE96226CB48A00C22AFC /* SceneCoordinator.swift */, - 5195C1D92720205F00888867 /* ShadowTableChanges.swift */, 514B7C8223205EFB00BAC947 /* RootSplitViewController.swift */, 511D4410231FC02D00FB1562 /* KeyboardManager.swift */, 51C45254226507D200C03939 /* AppAssets.swift */, diff --git a/iOS/MasterFeed/MasterFeedViewController.swift b/iOS/MasterFeed/MasterFeedViewController.swift index b906b422c..000eda5bc 100644 --- a/iOS/MasterFeed/MasterFeedViewController.swift +++ b/iOS/MasterFeed/MasterFeedViewController.swift @@ -572,7 +572,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { } } - func reloadFeeds(initialLoad: Bool, changes: [ShadowTableChanges], completion: (() -> Void)? = nil) { + func reloadFeeds(initialLoad: Bool, changes: ShadowTableChanges, completion: (() -> Void)? = nil) { updateUI() guard !initialLoad else { @@ -581,26 +581,36 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { return } - for change in changes { - guard !change.isEmpty else { continue } + tableView.performBatchUpdates { + if let deletes = changes.deletes, !deletes.isEmpty { + tableView.deleteSections(IndexSet(deletes), with: .middle) + } - tableView.performBatchUpdates { - if let deletes = change.deleteIndexPaths, !deletes.isEmpty { - tableView.deleteRows(at: deletes, with: .middle) + if let inserts = changes.inserts, !inserts.isEmpty { + tableView.insertSections(IndexSet(inserts), with: .middle) + } + + if let moves = changes.moves, !moves.isEmpty { + for move in moves { + tableView.moveSection(move.from, toSection: move.to) } - - 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 rowChanges = changes.rowChanges { + for rowChange in rowChanges { + if let deletes = rowChange.deleteIndexPaths, !deletes.isEmpty { + tableView.deleteRows(at: deletes, with: .middle) + } + + if let inserts = rowChange.insertIndexPaths, !inserts.isEmpty { + tableView.insertRows(at: inserts, with: .middle) + } + + if let moves = rowChange.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) } } } diff --git a/iOS/MasterFeed/ShadowTableChanges.swift b/iOS/MasterFeed/ShadowTableChanges.swift new file mode 100644 index 000000000..dd8a12801 --- /dev/null +++ b/iOS/MasterFeed/ShadowTableChanges.swift @@ -0,0 +1,70 @@ +// +// ShadowTableChanges.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 10/20/21. +// Copyright © 2021 Ranchero Software. All rights reserved. +// + +import Foundation + +struct ShadowTableChanges { + + struct Move: Hashable { + var from: Int + var to: Int + + init(_ from: Int, _ to: Int) { + self.from = from + self.to = to + } + } + + struct RowChanges { + + var section: Int + var deletes: Set? + var inserts: Set? + var moves: Set? + + var isEmpty: Bool { + return (deletes?.isEmpty ?? true) && (inserts?.isEmpty ?? true) && (moves?.isEmpty ?? true) + } + + var deleteIndexPaths: [IndexPath]? { + guard let deletes = deletes else { return nil } + return deletes.map { IndexPath(row: $0, section: section) } + } + + var insertIndexPaths: [IndexPath]? { + guard let inserts = inserts else { return nil } + return inserts.map { IndexPath(row: $0, section: section) } + } + + 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)) } + } + + init(section: Int, deletes: Set?, inserts: Set?, moves: Set?) { + self.section = section + self.deletes = deletes + self.inserts = inserts + self.moves = moves + } + + } + + var deletes: Set? + var inserts: Set? + var moves: Set? + var rowChanges: [RowChanges]? + + init(deletes: Set?, inserts: Set?, moves: Set?, rowChanges: [RowChanges]?) { + self.deletes = deletes + self.inserts = inserts + self.moves = moves + self.rowChanges = rowChanges + } + +} diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index 82250d964..8e7610cd5 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -89,7 +89,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner { private var expandedTable = Set() private var readFilterEnabledTable = [FeedIdentifier: Bool]() - private var shadowTable = [[FeedNode]]() + private var shadowTable = [(sectionID: String, feedNodes: [FeedNode])]() private(set) var preSearchTimelineFeed: Feed? private var lastSearchString = "" @@ -205,8 +205,8 @@ class SceneCoordinator: NSObject, UndoableCommandRunner { let prevIndexPath: IndexPath? = { if indexPath.row - 1 < 0 { for i in (0.. 0 { - return IndexPath(row: shadowTable[i].count - 1, section: i) + if shadowTable[i].feedNodes.count > 0 { + return IndexPath(row: shadowTable[i].feedNodes.count - 1, section: i) } } return nil @@ -224,9 +224,9 @@ class SceneCoordinator: NSObject, UndoableCommandRunner { } let nextIndexPath: IndexPath? = { - if indexPath.row + 1 >= shadowTable[indexPath.section].count { + if indexPath.row + 1 >= shadowTable[indexPath.section].feedNodes.count { for i in indexPath.section + 1.. 0 { + if shadowTable[i].feedNodes.count > 0 { return IndexPath(row: 0, section: i) } } @@ -316,7 +316,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner { for sectionNode in treeController.rootNode.childNodes { markExpanded(sectionNode) - shadowTable.append([FeedNode]()) + shadowTable.append((sectionID: "", feedNodes: [FeedNode]())) } NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidInitialize(_:)), name: .UnreadCountDidInitialize, object: nil) @@ -675,19 +675,19 @@ class SceneCoordinator: NSObject, UndoableCommandRunner { } func numberOfRows(in section: Int) -> Int { - return shadowTable[section].count + return shadowTable[section].feedNodes.count } func nodeFor(_ indexPath: IndexPath) -> Node? { - guard indexPath.section < shadowTable.count && indexPath.row < shadowTable[indexPath.section].count else { + guard indexPath.section < shadowTable.count && indexPath.row < shadowTable[indexPath.section].feedNodes.count else { return nil } - return shadowTable[indexPath.section][indexPath.row].node + return shadowTable[indexPath.section].feedNodes[indexPath.row].node } func indexPathFor(_ node: Node) -> IndexPath? { for i in 0.. IndexPath { - guard indexPath.section < shadowTable.count && indexPath.row < shadowTable[indexPath.section].count else { - return IndexPath(row: shadowTable[shadowTable.count - 1].count - 1, section: shadowTable.count - 1) + guard indexPath.section < shadowTable.count && indexPath.row < shadowTable[indexPath.section].feedNodes.count else { + return IndexPath(row: shadowTable[shadowTable.count - 1].feedNodes.count - 1, section: shadowTable.count - 1) } return indexPath } @@ -1502,7 +1502,7 @@ private extension SceneCoordinator { func addShadowTableToFilterExceptions() { for section in shadowTable { - for feedNode in section { + for feedNode in section.feedNodes { if let feed = feedNode.node.representedObject as? Feed, let feedID = feed.feedID { treeControllerDelegate.addFilterException(feedID) } @@ -1530,26 +1530,27 @@ private extension SceneCoordinator { } } - func rebuildShadowTable() -> [ShadowTableChanges] { - var newShadowTable = [[FeedNode]]() + func rebuildShadowTable() -> ShadowTableChanges { + var newShadowTable = [(sectionID: String, feedNodes: [FeedNode])]() for i in 0..() var inserts = Set() var deletes = Set() - let diff = newSectionNodes.difference(from: shadowTable[index]).inferringMoves() + let oldFeedNodes = shadowTable.first(where: { $0.sectionID == newSectionRows.sectionID })?.feedNodes ?? [FeedNode]() + + let diff = newSectionRows.feedNodes.difference(from: oldFeedNodes).inferringMoves() for change in diff { switch change { case .insert(let offset, _, let associated): @@ -1583,17 +1586,42 @@ private extension SceneCoordinator { } } - changes.append(ShadowTableChanges(section: index, deletes: deletes, inserts: inserts, moves: moves)) + changes.append(ShadowTableChanges.RowChanges(section: section, deletes: deletes, inserts: inserts, moves: moves)) } + + // Compute the difference in the shadow table sections + var moves = Set() + var inserts = Set() + var deletes = Set() + let oldSections = shadowTable.map { $0.sectionID } + let newSections = newShadowTable.map { $0.sectionID } + let diff = newSections.difference(from: oldSections).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) + } + } + } + shadowTable = newShadowTable - return changes + return ShadowTableChanges(deletes: deletes, inserts: inserts, moves: moves, rowChanges: changes) } func shadowTableContains(_ feed: Feed) -> Bool { for section in shadowTable { - for feedNode in section { + for feedNode in section.feedNodes { if let nodeFeed = feedNode.node.representedObject as? Feed, nodeFeed.feedID == feed.feedID { return true } @@ -1742,9 +1770,9 @@ private extension SceneCoordinator { let nextIndexPath: IndexPath = { if indexPath.row - 1 < 0 { if indexPath.section - 1 < 0 { - return IndexPath(row: shadowTable[shadowTable.count - 1].count - 1, section: shadowTable.count - 1) + return IndexPath(row: shadowTable[shadowTable.count - 1].feedNodes.count - 1, section: shadowTable.count - 1) } else { - return IndexPath(row: shadowTable[indexPath.section - 1].count - 1, section: indexPath.section - 1) + return IndexPath(row: shadowTable[indexPath.section - 1].feedNodes.count - 1, section: indexPath.section - 1) } } else { return IndexPath(row: indexPath.row - 1, section: indexPath.section) @@ -1754,7 +1782,7 @@ private extension SceneCoordinator { if selectPrevUnreadFeedFetcher(startingWith: nextIndexPath) { return } - let maxIndexPath = IndexPath(row: shadowTable[shadowTable.count - 1].count - 1, section: shadowTable.count - 1) + let maxIndexPath = IndexPath(row: shadowTable[shadowTable.count - 1].feedNodes.count - 1, section: shadowTable.count - 1) selectPrevUnreadFeedFetcher(startingWith: maxIndexPath) } @@ -1768,7 +1796,7 @@ private extension SceneCoordinator { if indexPath.section == i { return indexPath.row } else { - return shadowTable[i].count - 1 + return shadowTable[i].feedNodes.count - 1 } }() @@ -1847,7 +1875,7 @@ private extension SceneCoordinator { // Increment or wrap around the IndexPath let nextIndexPath: IndexPath = { - if indexPath.row + 1 >= shadowTable[indexPath.section].count { + if indexPath.row + 1 >= shadowTable[indexPath.section].feedNodes.count { if indexPath.section + 1 >= shadowTable.count { return IndexPath(row: 0, section: 0) } else { @@ -1882,7 +1910,7 @@ private extension SceneCoordinator { } }() - for j in startingRow..? - 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 - } - -}