frenzy-ios/Reader/Screens/Items/ItemsViewController.swift

232 lines
10 KiB
Swift

//
// ItemsViewController.swift
// Reader
//
// Created by Shadowfacts on 1/9/22.
//
import UIKit
import CoreData
import SafariServices
protocol ItemsViewControllerDelegate: AnyObject {
func showReadItem(_ item: Item)
}
class ItemsViewController: UIViewController {
weak var delegate: ItemsViewControllerDelegate?
let fervorController: FervorController
let fetchRequest: NSFetchRequest<NSManagedObjectID>
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, NSManagedObjectID>!
private var batchUpdates: [() -> Void] = []
init(fetchRequest: NSFetchRequest<NSManagedObjectID>, fervorController: FervorController) {
precondition(fetchRequest.entityName == "Item")
self.fervorController = fervorController
self.fetchRequest = fetchRequest
super.init(nibName: nil, bundle: nil)
}
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)]
do {
let ids = try fervorController.persistentContainer.viewContext.fetch(fetchRequest)
var snapshot = NSDiffableDataSourceSnapshot<Section, NSManagedObjectID>()
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)
}
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, NSManagedObjectID> {
let itemCell = UICollectionView.CellRegistration<ItemCollectionViewCell, NSManagedObjectID> { [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)
}
}
@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
}
}
// 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: false)
}
}
} else {
DispatchQueue.main.async {
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)
}
}
}