diff --git a/Reader/Screens/Home/HomeViewController.swift b/Reader/Screens/Home/HomeViewController.swift index d487af7..33e3304 100644 --- a/Reader/Screens/Home/HomeViewController.swift +++ b/Reader/Screens/Home/HomeViewController.swift @@ -189,6 +189,22 @@ extension HomeViewController { return req } + var idFetchRequest: NSFetchRequest { + let req = NSFetchRequest(entityName: "Item") + req.resultType = .managedObjectIDResultType + switch self { + case .unread: + req.predicate = NSPredicate(format: "read = NO") + case .all: + break + case .group(let group): + req.predicate = NSPredicate(format: "feed in %@", group.feeds!) + case .feed(let feed): + req.predicate = NSPredicate(format: "feed = %@", feed) + } + return req + } + var countFetchRequest: NSFetchRequest? { let req = Reader.Item.fetchRequest() switch self { @@ -229,7 +245,7 @@ extension HomeViewController: UICollectionViewDelegate { guard let item = dataSource.itemIdentifier(for: indexPath) else { return } - let vc = ItemsViewController(fetchRequest: item.fetchRequest, fervorController: fervorController) + let vc = ItemsViewController(fetchRequest: item.idFetchRequest, fervorController: fervorController) vc.title = item.title vc.delegate = itemsDelegate show(vc, sender: nil) @@ -241,7 +257,7 @@ extension HomeViewController: UICollectionViewDelegate { return nil } return UIContextMenuConfiguration(identifier: nil, previewProvider: { - return ItemsViewController(fetchRequest: item.fetchRequest, fervorController: self.fervorController) + return ItemsViewController(fetchRequest: item.idFetchRequest, fervorController: self.fervorController) }, actionProvider: nil) } diff --git a/Reader/Screens/Items/ItemsViewController.swift b/Reader/Screens/Items/ItemsViewController.swift index a384fc2..539b8f7 100644 --- a/Reader/Screens/Items/ItemsViewController.swift +++ b/Reader/Screens/Items/ItemsViewController.swift @@ -18,14 +18,16 @@ class ItemsViewController: UIViewController { weak var delegate: ItemsViewControllerDelegate? let fervorController: FervorController - let fetchRequest: NSFetchRequest + let fetchRequest: NSFetchRequest private var collectionView: UICollectionView! - private var resultsController: NSFetchedResultsController! + private var dataSource: UICollectionViewDiffableDataSource! private var batchUpdates: [() -> Void] = [] - init(fetchRequest: NSFetchRequest, fervorController: FervorController) { + init(fetchRequest: NSFetchRequest, fervorController: FervorController) { + precondition(fetchRequest.entityName == "Item") + self.fervorController = fervorController self.fetchRequest = fetchRequest @@ -47,12 +49,11 @@ class ItemsViewController: UIViewController { configuration.backgroundColor = .clear let layout = UICollectionViewCompositionalLayout.list(using: configuration) collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) - collectionView.delegate = self - collectionView.dataSource = self collectionView.translatesAutoresizingMaskIntoConstraints = false - collectionView.register(ItemCollectionViewCell.self, forCellWithReuseIdentifier: "itemCell") view.addSubview(collectionView) + dataSource = createDataSource() + NSLayoutConstraint.activate([ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), @@ -61,69 +62,116 @@ class ItemsViewController: UIViewController { ]) fetchRequest.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)] - fetchRequest.fetchBatchSize = 20 - resultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: fervorController.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil) - resultsController.delegate = self - try! resultsController.performFetch() - } - -} - -extension ItemsViewController: UICollectionViewDataSource { - func numberOfSections(in collectionView: UICollectionView) -> Int { - return 1 - } - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return resultsController.fetchedObjects?.count ?? 0 - } - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "itemCell", for: indexPath) as! ItemCollectionViewCell - cell.delegate = self - cell.scrollView = collectionView - cell.updateUI(item: resultsController.fetchedObjects![indexPath.row]) - return cell - } -} - -extension ItemsViewController: NSFetchedResultsControllerDelegate { - func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - batchUpdates = [] - } - func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - collectionView.performBatchUpdates { - for update in self.batchUpdates { - update() - } + do { + let ids = try fervorController.persistentContainer.viewContext.fetch(fetchRequest) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.items]) + snapshot.appendItems(ids, toSection: .items) + dataSource.apply(snapshot, animatingDifferences: false) + + NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: fervorController.persistentContainer.viewContext) + + } catch { + let alert = UIAlertController(title: "Error fetching items", message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil)) + present(alert, animated: true) } - // clear to prevent retain cycles - batchUpdates = [] } - func controller(_ controller: NSFetchedResultsController, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) { - guard let indexPath = indexPath else { - return + + private func createDataSource() -> UICollectionViewDiffableDataSource { + let itemCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in + cell.delegate = self + cell.scrollView = self.collectionView + let item = self.fervorController.persistentContainer.viewContext.object(with: itemIdentifier) as! Item + cell.updateUI(item: item) } - batchUpdates.append { - switch type { - case .insert: - self.collectionView.insertItems(at: [indexPath]) - case .delete: - self.collectionView.deleteItems(at: [indexPath]) - case .move: - if let newIndexPath = newIndexPath { - self.collectionView.moveItem(at: indexPath, to: newIndexPath) + return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in + return collectionView.dequeueConfiguredReusableCell(using: itemCell, for: indexPath, item: itemIdentifier) + } + } + + @objc private func managedObjectsDidChange(_ notification: Notification) { + let inserted = notification.userInfo?[NSInsertedObjectsKey] as? NSSet + let updated = notification.userInfo?[NSUpdatedObjectsKey] as? NSSet + let deleted = notification.userInfo?[NSDeletedObjectsKey] as? NSSet + + // managed objectss from the notification are tied to the thread it was delivered on + // so get the published dates and evaluate the predicate here + let insertedItems = inserted?.compactMap { $0 as? Item }.filter { fetchRequest.predicate?.evaluate(with: $0) ?? true }.map { ($0, $0.published) } + + var snapshot = self.dataSource.snapshot() + + if let updated = updated { + snapshot.reconfigureItems(updated.compactMap { ($0 as? Item)?.objectID }) + } + if let deleted = deleted { + snapshot.deleteItems(deleted.compactMap { ($0 as? Item)?.objectID }) + } + + if let insertedItems = insertedItems { + self.fervorController.persistentContainer.performBackgroundTask { ctx in + + // for newly inserted items, the ctx doesn't have the published date so we check the data we got from the notification + func publishedForItem(_ id: NSManagedObjectID) -> Date? { + if let (_, pub) = insertedItems.first(where: { $0.0.objectID == id }) { + return pub + } else { + return (ctx.object(with: id) as! Item).published + } } - case .update: - self.collectionView.reloadItems(at: [indexPath]) - @unknown default: - break + + // todo: this feels inefficient + for (inserted, insertedPublished) in insertedItems { + // todo: uhh, what does sql do if the published date is nil? + guard let insertedPublished = insertedPublished else { + snapshot.insertItems([inserted.objectID], beforeItem: snapshot.itemIdentifiers.first!) + continue + } + + var index = 0 + while index < snapshot.itemIdentifiers.count, + let pub = publishedForItem(snapshot.itemIdentifiers[index]), + insertedPublished < pub { + index += 1 + } + + // index is the index of the first item which the inserted one was published after + // (i.e., the item that should appear immediately after inserted in the list) + + if index == snapshot.itemIdentifiers.count { + snapshot.appendItems([inserted.objectID], toSection: .items) + } else { + snapshot.insertItems([inserted.objectID], beforeItem: snapshot.itemIdentifiers[index]) + } + } + + DispatchQueue.main.async { + self.dataSource.apply(snapshot, animatingDifferences: true) + } + } + } else { + DispatchQueue.main.async { + self.dataSource.apply(snapshot, animatingDifferences: true) } } } + +} + +extension ItemsViewController { + enum Section: Hashable { + case items + } } extension ItemsViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - let item = resultsController.fetchedObjects![indexPath.row] + guard let id = dataSource.itemIdentifier(for: indexPath) else { + return nil + } + let item = fervorController.persistentContainer.viewContext.object(with: id) as! Item + // let item = resultsController.fetchedObjects![indexPath.row] return UIContextMenuConfiguration(identifier: nil, previewProvider: { ReadViewController(item: item, fervorController: self.fervorController) }, actionProvider: { _ in