diff --git a/FlipcashTests/Chat/ChatViewControllerTests.swift b/FlipcashTests/Chat/ChatViewControllerTests.swift index 675eba8d6..a8d599ba5 100644 --- a/FlipcashTests/Chat/ChatViewControllerTests.swift +++ b/FlipcashTests/Chat/ChatViewControllerTests.swift @@ -183,6 +183,44 @@ struct ChatViewControllerTests { #expect(flashing.count == 1, "exactly one row should be flashing, not \(flashing.count)") } + @Test("A jump far up a transcript of tall rows lands with the target centred", arguments: [false, true]) + func scrollToMessage_tallRows_landsCentred(animated: Bool) async { + let controller = ChatViewController() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = controller + window.makeKeyAndVisible() + // Every row is several times the layout's 56pt estimate, as a photo is, so rows that have + // never been on screen sit at estimated positions until something lays them out. + let tall = Array(repeating: "a line long enough to wrap across the bubble", count: 6).joined(separator: " ") + let items = (0..<60).map { i in + ChatItem.message(ChatMessage(id: "msg-\(i)", text: "\(i) \(tall)", sender: i.isMultiple(of: 2) ? .me : .other)) + } + controller.update(items: items) + for _ in 0..<6 { + controller.view.layoutIfNeeded() + try? await Task.sleep(for: .milliseconds(40)) + } + + if animated { + controller.scrollToMessage(id: "msg-10") + } else { + // The deferred path: the page carrying the row arrives after the jump was asked for. + controller.update(items: Array(items.suffix(20))) + controller.scrollToMessage(id: "msg-10") + controller.update(items: items) + } + for _ in 0..<15 { + controller.view.layoutIfNeeded() + try? await Task.sleep(for: .milliseconds(40)) + } + + let collectionView = controller.collectionView! + let frame = collectionView.layoutAttributesForItem(at: IndexPath(item: 10, section: 0))!.frame + let visible = collectionView.bounds.inset(by: collectionView.adjustedContentInset) + #expect(visible.contains(frame), "target \(frame) should be on screen in \(visible)") + #expect(abs(frame.midY - visible.midY) < 2, "target centre \(frame.midY) vs visible centre \(visible.midY)") + } + @Test("A row asked for before it is loaded flashes once the page carrying it arrives") func scrollToMessage_pendingTarget_flashesOnArrival() { let controller = ChatViewController() diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 3c7a5128a..61953328f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -1005,21 +1005,41 @@ public final class ChatViewController: UICollectionViewController { /// Centers a row that is already in `items` and flashes it, so the jump lands on a message the /// eye can pick out of the transcript rather than on an unmarked one in the middle of the screen. + /// + /// Rows that have never been on screen sit at the layout's 56pt estimate, so an offset worked out + /// before the jump lands short of a target far up a transcript of photos. The destination is + /// settled by re-anchoring on the target until the rows around it have self-sized, then the scroll + /// runs to that measured offset. private func scrollToRow(id: String, animated: Bool) { guard let index = items.firstIndex(where: { $0.messageID == id }) else { return } let indexPath = IndexPath(item: index, section: 0) positionClaim += 1 let claim = positionClaim guard animated else { - collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false) + centerRow(at: indexPath) wasAtBottom = isNearBottomByGeometry flashAttention(forStableID: id) + // Re-anchor once the rows the jump brought on screen have self-sized. + DispatchQueue.main.async { [weak self] in + guard let self, positionClaim == claim else { return } + centerRow(at: indexPath) + wasAtBottom = isNearBottomByGeometry + } return } + // Measure the destination, then put the reader back where they were so the scroll starts + // from what is on screen. All of it runs before the next frame, so none of it is drawn. + let origin = chatLayout.getContentOffsetSnapshot(from: .bottom) + centerRow(at: indexPath) + centerRow(at: indexPath) + let destination = collectionView.contentOffset + if let origin { chatLayout.restoreContentOffset(with: origin) } ChatMotion.scroll.animate { - self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false) + self.collectionView.setContentOffset(destination, animated: false) } completion: { _ in guard self.positionClaim == claim else { return } + // Rows between the reader and the target self-size as the scroll passes them. + self.centerRow(at: indexPath) self.wasAtBottom = self.isNearBottomByGeometry } // Alongside the scroll rather than after it: the flash's rise is shorter than the scroll's @@ -1028,6 +1048,16 @@ public final class ChatViewController: UICollectionViewController { flashAttention(forStableID: id) } + /// Puts the row at `indexPath` in the middle of the visible area by its current height, without + /// animating. A row taller than the visible area is top-aligned instead, so it reads from its start. + private func centerRow(at indexPath: IndexPath) { + guard let frame = chatLayout.layoutAttributesForItem(at: indexPath)?.frame else { return } + let inset = collectionView.adjustedContentInset + let visibleHeight = collectionView.bounds.height - inset.top - inset.bottom + let offset = max(0, (visibleHeight - frame.height) / 2) - chatLayout.settings.additionalInsets.top + chatLayout.restoreContentOffset(with: ChatLayoutPositionSnapshot(indexPath: indexPath, edge: .top, offset: offset)) + } + /// Puts the top of the row at `indexPath` at the top of the visible area, without animating. The /// top-aligned counterpart to ``scrollToRow(id:animated:)``'s centering, for a heading the reader /// should read down from.