mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
Refactor Diffable Datasources out of the Sidebar
This commit is contained in:
23
iOS/MasterFeed/Cell/MasterFeedRowIdentifier.swift
Normal file
23
iOS/MasterFeed/Cell/MasterFeedRowIdentifier.swift
Normal file
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<Int, MasterFeedTableViewIdentifier> {
|
||||
|
||||
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||
guard let identifier = itemIdentifier(for: indexPath), identifier.isEditable else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<Int, MasterFeedTableViewIdentifier>
|
||||
private var snapshot: NSDiffableDataSourceSnapshot<Int, MasterFeedTableViewIdentifier>
|
||||
private var animating: Bool
|
||||
|
||||
init(dataSource: UITableViewDiffableDataSource<Int, MasterFeedTableViewIdentifier>, snapshot: NSDiffableDataSourceSnapshot<Int, MasterFeedTableViewIdentifier>, 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Int, MasterFeedTableViewIdentifier>()
|
||||
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<Int, MasterFeedTableViewIdentifier>, 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ContainerIdentifier>()
|
||||
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..<shadowTable.count {
|
||||
if let row = shadowTable[i].firstIndex(of: FeedNode(node)) {
|
||||
return IndexPath(row: row, section: i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func articleFor(_ articleID: String) -> 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..<treeController.rootNode.numberOfChildNodes {
|
||||
|
||||
var result = [Node]()
|
||||
var result = [FeedNode]()
|
||||
let sectionNode = treeController.rootNode.childAtIndex(i)!
|
||||
|
||||
if isExpanded(sectionNode) {
|
||||
for node in sectionNode.childNodes {
|
||||
result.append(node)
|
||||
result.append(FeedNode(node))
|
||||
if isExpanded(node) {
|
||||
for child in node.childNodes {
|
||||
result.append(child)
|
||||
result.append(FeedNode(child))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shadowTable.append(result)
|
||||
|
||||
newShadowTable.append(result)
|
||||
}
|
||||
|
||||
// If we have a current Feed IndexPath it is no longer valid and needs reset.
|
||||
if currentFeedIndexPath != nil {
|
||||
currentFeedIndexPath = indexPathFor(timelineFeed as AnyObject)
|
||||
}
|
||||
|
||||
// Compute the differences in the shadow tables
|
||||
var changes = [ShadowTableChanges]()
|
||||
|
||||
for (index, newSectionNodes) in newShadowTable.enumerated() {
|
||||
var moves = Set<ShadowTableChanges.Move>()
|
||||
var inserts = Set<Int>()
|
||||
var deletes = Set<Int>()
|
||||
|
||||
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..<shadowTable.count {
|
||||
if let row = shadowTable[i].firstIndex(of: node) {
|
||||
return IndexPath(row: row, section: i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func indexPathFor(_ object: AnyObject) -> IndexPath? {
|
||||
guard let node = treeController.rootNode.descendantNodeRepresentingObject(object) else {
|
||||
return nil
|
||||
|
||||
65
iOS/ShadowTableChanges.swift
Normal file
65
iOS/ShadowTableChanges.swift
Normal file
@@ -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<Int>?
|
||||
public var inserts: Set<Int>?
|
||||
public var moves: Set<Move>?
|
||||
public var reloads: Set<Int>?
|
||||
|
||||
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<Int>? = nil, inserts: Set<Int>? = nil, moves: Set<Move>? = nil, reloads: Set<Int>? = nil) {
|
||||
self.section = section
|
||||
self.deletes = deletes
|
||||
self.inserts = inserts
|
||||
self.moves = moves
|
||||
self.reloads = reloads
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user