Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions FlipcashTests/Chat/ChatViewControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
34 changes: 32 additions & 2 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Loading