// // ItemsViewController.swift // Reader // // Created by Shadowfacts on 1/9/22. // import UIKit import CoreData import SafariServices import Persistence protocol ItemsViewControllerDelegate: AnyObject { func showReadItem(_ item: Item) } class ItemsViewController: UIViewController { weak var delegate: ItemsViewControllerDelegate? let fervorController: FervorController let type: ItemListType private let fetchRequest: NSFetchRequest private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private var batchUpdates: [() -> Void] = [] init(type: ItemListType, fervorController: FervorController) { self.fervorController = fervorController self.type = type self.fetchRequest = type.idFetchRequest super.init(nibName: nil, bundle: nil) self.title = type.title switch type { case .all: self.userActivity = .readAll(account: fervorController.account!) case .unread: self.userActivity = .readUnread(account: fervorController.account!) case .group(let group): self.userActivity = .readGroup(group, account: fervorController.account!) case .feed(let feed): self.userActivity = .readFeed(feed, account: fervorController.account!) } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() if UIDevice.current.userInterfaceIdiom != .mac { view.backgroundColor = .appBackground } var configuration = UICollectionLayoutListConfiguration(appearance: .plain) configuration.backgroundColor = .clear let layout = UICollectionViewCompositionalLayout.list(using: configuration) collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) collectionView.delegate = self collectionView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(collectionView) dataSource = createDataSource() NSLayoutConstraint.activate([ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), collectionView.topAnchor.constraint(equalTo: view.topAnchor), collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) fetchRequest.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)] fetchItems() NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: fervorController.persistentContainer.viewContext) } 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) } return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in return collectionView.dequeueConfiguredReusableCell(using: itemCell, for: indexPath, item: itemIdentifier) } } private func fetchItems() { 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) } 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) } } private var updateId = 0 @objc private func managedObjectsDidChange(_ notification: Notification) { let id = updateId updateId += 1 print("\(id) Managed objects did change") let inserted = notification.userInfo?[NSInsertedObjectsKey] as? NSSet let updated = notification.userInfo?[NSUpdatedObjectsKey] as? NSSet let deleted = notification.userInfo?[NSDeletedObjectsKey] as? NSSet let hasInsertedItems = inserted?.lazy.compactMap { $0 as? Item }.contains { fetchRequest.predicate?.evaluate(with: $0) ?? true } ?? false if hasInsertedItems { print("\(id) Has inserted items, skipping merge") // if any items were inserted, just refetch everything. it's more expensive than it's worth to try and merge the changes into the current snapshot self.fetchItems() return } var snapshot = self.dataSource.snapshot() // the itemIdentifiers getter takes a lot of time in profiles, so only call the getter once let snapshotItems = snapshot.itemIdentifiers if let updated = updated { print("\(id) Updated: \(updated.count)") let knownUpdated = updated.compactMap { ($0 as? Item)?.objectID }.filter { snapshotItems.contains($0) } snapshot.reconfigureItems(knownUpdated) } if let deleted = deleted { print("\(id) Deleted: \(deleted.count)") let knownDeleted = deleted.compactMap { ($0 as? Item)?.objectID }.filter { snapshotItems.contains($0) } snapshot.deleteItems(knownDeleted) } print("\(id) Applying snapshot") self.dataSource.apply(snapshot, animatingDifferences: false) } } extension ItemsViewController { enum Section: Hashable { case items } } extension ItemsViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard let id = dataSource.itemIdentifier(for: indexPath) else { return nil } let item = fervorController.persistentContainer.viewContext.object(with: id) as! Item return UIContextMenuConfiguration(identifier: nil, previewProvider: { ReadViewController(item: item, fervorController: self.fervorController) }, actionProvider: { _ in var children: [UIMenuElement] = [] if let url = item.url { children.append(UIAction(title: "Open in Safari", image: UIImage(systemName: "safari"), handler: { [weak self] _ in let vc = SFSafariViewController(url: url) vc.preferredControlTintColor = .appTintColor self?.present(vc, animated: true) })) #if targetEnvironment(macCatalyst) self.activityItemsConfiguration = UIActivityItemsConfiguration(objects: [url as NSURL]) children.append(UICommand(title: "Share…", action: Selector(("unused")), propertyList: UICommandTagShare)) #else children.append(UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up"), handler: { [weak self] _ in self?.present(UIActivityViewController(activityItems: [url], applicationActivities: nil), animated: true) })) #endif } if item.read { children.append(UIAction(title: "Mark as Unread", image: UIImage(systemName: "checkmark.circle"), handler: { [unowned self] _ in Task { await self.fervorController.markItem(item, read: false) } })) } else { children.append(UIAction(title: "Mark as Read", image: UIImage(systemName: "checkmark.circle.fill"), handler: { [unowned self] _ in Task { await self.fervorController.markItem(item, read: true) } })) } return UIMenu(children: children) }) } func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { guard let vc = animator.previewViewController else { return } animator.preferredCommitStyle = .pop animator.addCompletion { self.show(vc, sender: nil) } } } extension ItemsViewController: ItemCollectionViewCellDelegate { func itemCellSelected(cell: ItemCollectionViewCell, item: Item) { cell.setRead(true, animated: true) if let delegate = delegate { delegate.showReadItem(item) } else { show(ReadViewController(item: item, fervorController: fervorController), sender: nil) } } }