// // HomeTimelineViewModel+Diffable.swift // Mastodon // // Created by sxiaojian on 2021/2/7. // import os.log import UIKit import CoreData import CoreDataStack extension HomeTimelineViewModel { func setupDiffableDataSource( tableView: UITableView, statusTableViewCellDelegate: StatusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate ) { diffableDataSource = StatusSection.diffableDataSource( tableView: tableView, context: context, configuration: StatusSection.Configuration( authContext: authContext, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, filterContext: .home, activeFilters: context.statusFilterService.$activeFilters ) ) // make initial snapshot animation smooth var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) diffableDataSource?.apply(snapshot) fetchedResultsController.$records .receive(on: DispatchQueue.main) .sink { [weak self] records in guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): incoming \(records.count) objects") Task { let start = CACurrentMediaTime() defer { let end = CACurrentMediaTime() self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): cost \(end - start, format: .fixed(precision: 4))s to process \(records.count) feeds") } let oldSnapshot = diffableDataSource.snapshot() var newSnapshot: NSDiffableDataSourceSnapshot = { let newItems = records.map { record in StatusItem.feed(record: record) } var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) snapshot.appendItems(newItems, toSection: .main) return snapshot }() let parentManagedObjectContext = self.context.managedObjectContext let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) managedObjectContext.parent = parentManagedObjectContext try? await managedObjectContext.perform { let anchors: [Feed] = { let request = Feed.sortedFetchRequest request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ Feed.hasMorePredicate(), self.fetchedResultsController.predicate, ]) do { return try managedObjectContext.fetch(request) } catch { assertionFailure(error.localizedDescription) return [] } }() let itemIdentifiers = newSnapshot.itemIdentifiers for (index, item) in itemIdentifiers.enumerated() { guard case let .feed(record) = item else { continue } guard anchors.contains(where: { feed in feed.objectID == record.objectID }) else { continue } let isLast = index + 1 == itemIdentifiers.count if isLast { newSnapshot.insertItems([.bottomLoader], afterItem: item) } else { newSnapshot.insertItems([.feedLoader(record: record)], afterItem: item) } } } let hasChanges = newSnapshot.itemIdentifiers != oldSnapshot.itemIdentifiers if !hasChanges { self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): snapshot not changes") self.didLoadLatest.send() return } else { self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): snapshot has changes") } guard let difference = await self.calculateReloadSnapshotDifference( tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot ) else { await self.updateSnapshotUsingReloadData(snapshot: newSnapshot) self.didLoadLatest.send() self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): applied new snapshot") return } await self.updateSnapshotUsingReloadData(snapshot: newSnapshot) await tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) var contentOffset = await tableView.contentOffset contentOffset.y = await tableView.contentOffset.y - difference.sourceDistanceToTableViewTopEdge await tableView.setContentOffset(contentOffset, animated: false) self.didLoadLatest.send() self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): applied new snapshot") } // end Task } .store(in: &disposeBag) } } extension HomeTimelineViewModel { @MainActor func updateDataSource( snapshot: NSDiffableDataSourceSnapshot, animatingDifferences: Bool ) async { diffableDataSource?.apply(snapshot, animatingDifferences: animatingDifferences) } @MainActor func updateSnapshotUsingReloadData( snapshot: NSDiffableDataSourceSnapshot ) async { if #available(iOS 15.0, *) { await self.diffableDataSource?.applySnapshotUsingReloadData(snapshot) } else { diffableDataSource?.applySnapshot(snapshot, animated: false, completion: nil) } } struct Difference { let item: T let sourceIndexPath: IndexPath let sourceDistanceToTableViewTopEdge: CGFloat let targetIndexPath: IndexPath } @MainActor private func calculateReloadSnapshotDifference( tableView: UITableView, oldSnapshot: NSDiffableDataSourceSnapshot, newSnapshot: NSDiffableDataSourceSnapshot ) -> Difference? { guard let sourceIndexPath = (tableView.indexPathsForVisibleRows ?? []).sorted().first else { return nil } let rectForSourceItemCell = tableView.rectForRow(at: sourceIndexPath) let sourceDistanceToTableViewTopEdge: CGFloat = { if tableView.window != nil { return tableView.convert(rectForSourceItemCell, to: nil).origin.y - tableView.safeAreaInsets.top } else { return rectForSourceItemCell.origin.y - tableView.contentOffset.y - tableView.safeAreaInsets.top } }() guard sourceIndexPath.section < oldSnapshot.numberOfSections, sourceIndexPath.row < oldSnapshot.numberOfItems(inSection: oldSnapshot.sectionIdentifiers[sourceIndexPath.section]) else { return nil } let sectionIdentifier = oldSnapshot.sectionIdentifiers[sourceIndexPath.section] let item = oldSnapshot.itemIdentifiers(inSection: sectionIdentifier)[sourceIndexPath.row] guard let targetIndexPathRow = newSnapshot.indexOfItem(item), let newSectionIdentifier = newSnapshot.sectionIdentifier(containingItem: item), let targetIndexPathSection = newSnapshot.indexOfSection(newSectionIdentifier) else { return nil } let targetIndexPath = IndexPath(row: targetIndexPathRow, section: targetIndexPathSection) return Difference( item: item, sourceIndexPath: sourceIndexPath, sourceDistanceToTableViewTopEdge: sourceDistanceToTableViewTopEdge, targetIndexPath: targetIndexPath ) } }