diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index c92e989e19..0d87442a04 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -246,17 +246,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro snapshot.insertItems(presentItems.map { .status(id: $0, state: .unknown) }, beforeItem: .gap) if applySnapshotBeforeScrolling { - // use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back - let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)! - snapshotView.layer.zPosition = 1000 - snapshotView.frame = view.bounds - view.addSubview(snapshotView) - - let bottomOffset = collectionView.contentSize.height - collectionView.contentOffset.y - self.dataSource.apply(snapshot, animatingDifferences: false) { - self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset) - snapshotView.removeFromSuperview() - } + let firstVisibleIndexPath = collectionView.indexPathsForVisibleItems.min()! + let firstVisibleItem = dataSource.itemIdentifier(for: firstVisibleIndexPath)! + applySnapshot(snapshot, maintainingBottomRelativeScrollPositionOf: firstVisibleItem) } var config = ToastConfiguration(title: "Jump to present") @@ -276,6 +268,50 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } } + // NOTE: this only works when items are being inserted ABOVE the item to maintain + private func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot, maintainingBottomRelativeScrollPositionOf itemToMaintain: Item) { + // use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back + let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)! + snapshotView.layer.zPosition = 1000 + snapshotView.frame = view.bounds + view.addSubview(snapshotView) + + var firstItemAfterOriginalGapOffsetFromTop: CGFloat = 0 + if let indexPath = dataSource.indexPath(for: itemToMaintain), + let cell = collectionView.cellForItem(at: indexPath) { + // subtract top safe area inset b/c scrollToItem at .top aligns the top of the cell to the top of the safe area + firstItemAfterOriginalGapOffsetFromTop = cell.convert(.zero, to: view).y - view.safeAreaInsets.top + } + + dataSource.apply(snapshot, animatingDifferences: false) { + if let indexPathOfItemAfterOriginalGap = self.dataSource.indexPath(for: itemToMaintain) { + // scroll up until we've accumulated enough MEASURED height that we can put the + // firstItemAfterOriginalGapCell at the top of the screen and then scroll down by + // firstItemAfterOriginalGapOffsetFromTop without intruding into unmeasured area + var cur = indexPathOfItemAfterOriginalGap + var amountScrolledUp: CGFloat = 0 + while true { + if cur.row <= 0 { + break + } + if let cell = self.collectionView.cellForItem(at: indexPathOfItemAfterOriginalGap), + cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top > firstItemAfterOriginalGapOffsetFromTop { + break + } + cur = IndexPath(row: cur.row - 1, section: cur.section) + self.collectionView.scrollToItem(at: cur, at: .top, animated: false) + self.collectionView.layoutIfNeeded() + let attrs = self.collectionView.layoutAttributesForItem(at: cur)! + amountScrolledUp += attrs.size.height + } + self.collectionView.contentOffset.y += amountScrolledUp + self.collectionView.contentOffset.y -= firstItemAfterOriginalGapOffsetFromTop + } + + snapshotView.removeFromSuperview() + } + } + } extension TimelineViewController { @@ -539,29 +575,8 @@ extension TimelineViewController { } if addedItems { - // use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back - let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)! - snapshotView.layer.zPosition = 1000 - snapshotView.frame = view.bounds - view.addSubview(snapshotView) - - // Yes, these are all load bearing. Setting the contentOffset seems to cause the collection view to recalculate the size - // of some cells, thus changing the contentSize and the offset necessary to match to match the bottom offset. - // Three DispatchQueue.main.async's seems to be the fewest we can reliably get away with. - let bottomOffset = collectionView.contentSize.height - collectionView.contentOffset.y - dataSource.apply(snapshot, animatingDifferences: false) { - self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset) - DispatchQueue.main.async { - self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset) - DispatchQueue.main.async { - self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset) - DispatchQueue.main.async { - self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset) - snapshotView.removeFromSuperview() - } - } - } - } + let firstItemAfterOriginalGap = statusItems[gapIndex + 1] + applySnapshot(snapshot, maintainingBottomRelativeScrollPositionOf: firstItemAfterOriginalGap) } else { dataSource.apply(snapshot, animatingDifferences: true) {} }