diff --git a/FlipcashTests/Chat/ChatViewControllerTests.swift b/FlipcashTests/Chat/ChatViewControllerTests.swift index b6469766e..1473ef8d1 100644 --- a/FlipcashTests/Chat/ChatViewControllerTests.swift +++ b/FlipcashTests/Chat/ChatViewControllerTests.swift @@ -160,6 +160,63 @@ struct ChatViewControllerTests { #expect(linkCell is ChatLinkMessageCell) #expect(plainCell is ChatMessageCell) } + + @Test("Jumping to a message flashes it, and only it") + func scrollToMessage_flashesTarget() async { + let controller = ChatViewController() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = controller + window.makeKeyAndVisible() + controller.update(items: (0..<40).map { item($0, $0.isMultiple(of: 2) ? .me : .other) }) + for _ in 0..<6 { + controller.view.layoutIfNeeded() + try? await Task.sleep(for: .milliseconds(40)) + } + + // Lands synchronously: the jump materializes the row it scrolls to rather than waiting for + // the next layout pass. + controller.scrollToMessage(id: "msg-30") + + let flashing = controller.collectionView.visibleCells + .compactMap { $0 as? ChatMessageCell } + .filter(\.bubbleView.isFlashingAttention) + #expect(flashing.count == 1, "exactly one row should be flashing, not \(flashing.count)") + } + + @Test("A row asked for before it is loaded flashes once the page carrying it arrives") + func scrollToMessage_pendingTarget_flashesOnArrival() { + let controller = ChatViewController() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = controller + window.makeKeyAndVisible() + controller.update(items: (20..<40).map { item($0) }) + controller.view.layoutIfNeeded() + + controller.scrollToMessage(id: "msg-5") + #expect(controller.collectionView.visibleCells.compactMap { $0 as? ChatMessageCell } + .allSatisfy { !$0.bubbleView.isFlashingAttention }) + + // The older page lands. Like the in-window jump above this is synchronous — the update + // carrying the row performs the waiting jump before it returns — so the flash is asserted at + // the same point, before ChatLayout starts settling the reloaded rows off their estimates. + controller.update(items: (0..<40).map { item($0) }) + + let flashing = controller.collectionView.visibleCells + .compactMap { $0 as? ChatMessageCell } + .filter(\.bubbleView.isFlashingAttention) + #expect(flashing.count == 1, "the arrived row should be the one flashing, not \(flashing.count) rows") + } + + @Test("A recycled bubble drops a flash meant for the row it was showing") + func flash_clearedWhenBubbleTakesAnotherRow() { + let bubble = ChatBubbleView() + bubble.configure(with: ChatMessage(id: "a", text: "first", sender: .me)) + bubble.flashAttention() + #expect(bubble.isFlashingAttention) + + bubble.configure(with: ChatMessage(id: "b", text: "second", sender: .me)) + #expect(!bubble.isFlashingAttention) + } } @MainActor diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift index 2705aee21..ea4150fb2 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift @@ -30,6 +30,7 @@ final class BubbleBackgroundView: UIView { private let shapeMask = CAShapeLayer() private let washLayer = CALayer() + private let attentionLayer = CALayer() private let borderLayer = CAShapeLayer() private var radii = RectangleCornerRadii(topLeading: baseRadius, bottomLeading: baseRadius, bottomTrailing: baseRadius, topTrailing: baseRadius) /// The message this chrome currently draws, so a radii change can be told apart from a recycled @@ -47,6 +48,12 @@ final class BubbleBackgroundView: UIView { // colour behind the bubble's own frame change. washLayer.actions = ["position": NSNull(), "bounds": NSNull()] layer.addSublayer(washLayer) + // Above the wash and below the border, so the flash brightens the bubble's ground without + // washing over its text or softening its hairline edge. + attentionLayer.backgroundColor = Self.attentionWash.cgColor + attentionLayer.opacity = 0 + attentionLayer.actions = ["position": NSNull(), "bounds": NSNull()] + layer.addSublayer(attentionLayer) borderLayer.fillColor = UIColor.clear.cgColor borderLayer.strokeColor = UIColor.white.withAlphaComponent(0.03).cgColor borderLayer.lineWidth = 1 @@ -62,6 +69,11 @@ final class BubbleBackgroundView: UIView { /// keeps a reused cell from animating in someone else's shape. func apply(fill: UIColor, radii: RectangleCornerRadii, identity: String? = nil) { washLayer.backgroundColor = fill.cgColor + // A recycled view taking a new row drops any flash still running, so the attention never + // finishes on a message it wasn't meant for. + if identity != self.identity { + attentionLayer.removeAnimation(forKey: Self.attentionKey) + } pendingCornerMorph = identity != nil && identity == self.identity && radii != self.radii self.identity = identity self.radii = radii @@ -80,6 +92,7 @@ final class BubbleBackgroundView: UIView { let path = UnevenRoundedRectangle(cornerRadii: radii, style: .continuous).path(in: bounds).cgPath shapeMask.path = path washLayer.frame = bounds + attentionLayer.frame = bounds borderLayer.path = path borderLayer.frame = bounds @@ -93,6 +106,45 @@ final class BubbleBackgroundView: UIView { } } + // MARK: - Attention + + private static let attentionKey = "attention" + + /// Brightens the bubble's ground for `ChatMotion.attentionDuration`, then lets it fade back. + /// + /// Runs as a keyframe on the layer rather than a `UIView` animation because the resting opacity + /// must stay 0 throughout: the row can be reconfigured or recycled mid-flash, and a model value + /// left raised would strand a lit bubble. + /// + /// `start` is when the flash began, in `CACurrentMediaTime()`'s clock. Passing a time already + /// past joins a flash in progress rather than restarting it, so a row re-dequeued mid-flash + /// picks it up where it left off and still ends when it would have. + func flashAttention(startedAt start: CFTimeInterval = CACurrentMediaTime()) { + let rise = ChatMotion.attentionRise + let hold = ChatMotion.attentionHold + let total = ChatMotion.attentionDuration + let flash = CAKeyframeAnimation(keyPath: "opacity") + flash.values = [0, 1, 1, 0] + flash.keyTimes = [0, NSNumber(value: rise / total), NSNumber(value: (rise + hold) / total), 1] + flash.timingFunctions = [ + CAMediaTimingFunction(name: .easeOut), + CAMediaTimingFunction(name: .linear), + CAMediaTimingFunction(name: .easeInEaseOut), + ] + flash.duration = total + flash.beginTime = start + attentionLayer.removeAnimation(forKey: Self.attentionKey) + attentionLayer.add(flash, forKey: Self.attentionKey) + } + + /// Whether an attention flash is currently running on this chrome. + var isFlashingAttention: Bool { attentionLayer.animation(forKey: Self.attentionKey) != nil } + + /// The lift the flash adds on top of the sender's resting wash. Sized to read on both fills — + /// a received bubble sits at 0.02 white, so the same absolute lift is the larger relative jump + /// there, which is right: the message being pointed at is usually the other person's. + private static let attentionWash = UIColor.white.withAlphaComponent(0.10) + /// The elevation a bubble sits at once it has been lifted out of the transcript. /// /// Set by hand rather than left to UIKit. A `UITargetedPreview` built with a clear background diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift index 7581c10d6..9a80cb756 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift @@ -100,6 +100,12 @@ public final class ChatBubbleView: UIView { /// The background is pinned to every edge, so its bounds match the bubble's. var maskingPath: UIBezierPath { background.maskingPath } + /// Flashes the bubble's ground to point the eye at this message after a jump. + func flashAttention(startedAt start: CFTimeInterval = CACurrentMediaTime()) { background.flashAttention(startedAt: start) } + + /// Whether this bubble is currently flashing. + var isFlashingAttention: Bool { background.isFlashingAttention } + public func configure(with message: ChatMessage) { label.attributedText = Self.displayText(for: message) editedLabel.isHidden = !Self.showsEditedMarker(for: message) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift index 68b1f2ec4..55f2dab15 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift @@ -159,6 +159,8 @@ extension ChatCashCardCell: BubbleCarrying { /// full-width rectangle out of the transcript. var liftPreviewView: UIView { card } var liftPreviewMaskingPath: UIBezierPath? { card.maskingPath } + + func flashAttention(startedAt start: CFTimeInterval) { card.flashAttention(startedAt: start) } } #Preview("Cash cards") { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift index ad3ef608e..4f1d3e10d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift @@ -36,6 +36,8 @@ public final class ChatLinkMessageCell: ChatColumnCell { var liftPreviewView: UIView { bubble } var liftPreviewMaskingPath: UIBezierPath? { bubble.maskingPath } + func flashAttention(startedAt start: CFTimeInterval) { bubble.flashAttention(startedAt: start) } + public override init(frame: CGRect) { super.init(frame: frame) installColumn(content: bubble) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift index b4ea4506c..3cc3e3bf5 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift @@ -53,6 +53,7 @@ public final class ChatMessageCell: ChatColumnCell { extension ChatMessageCell: BubbleCarrying { var liftPreviewView: UIView { bubbleView } var liftPreviewMaskingPath: UIBezierPath? { bubbleView.maskingPath } + func flashAttention(startedAt start: CFTimeInterval) { bubbleView.flashAttention(startedAt: start) } } #Preview("Cells") { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift index 13fa77e2f..1a28f7eac 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift @@ -150,6 +150,15 @@ public nonisolated enum ChatMotion { // MARK: - Timing + /// The attention flash a jumped-to message plays when a reply quote is tapped, in three parts: + /// it lights quickly, holds long enough to be found by eye after the scroll settles, then fades + /// slowly so the transcript is left as it was rather than switched back. + public static let attentionRise: TimeInterval = 0.15 + public static let attentionHold: TimeInterval = 0.45 + public static let attentionFade: TimeInterval = 0.40 + /// The whole flash, end to end. + public static var attentionDuration: TimeInterval { attentionRise + attentionHold + attentionFade } + /// How long a sent message holds before its "Delivered" line appears. A floor, not a fixed /// delay: the line waits for server confirmation too, whichever is later. public static let deliveredDelay: TimeInterval = 0.70 diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 6bbeea5b3..6a375cf7a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -94,6 +94,14 @@ public final class ChatViewController: UICollectionViewController { /// A row asked for before it was in `items` — the loader's window has to move first. The next /// update carrying it performs the scroll. private var pendingScrollTargetID: String? + /// The row a jump is pointing at and when its flash began, held for the flash's length so a cell + /// dequeued for that row mid-flash is lit too. + private var attention: (id: String, startedAt: CFTimeInterval)? + + /// Bumped whenever a jump takes ownership of where the transcript sits. A scroll-to-bottom's + /// deferred re-anchor captures the value it was queued under and gives way if the count has + /// moved since, so a jump landing before that block runs isn't pulled back to the newest message. + private var positionClaim = 0 /// Drag a row towards the leading edge to reply to it. Owns its own recognizer and state — see /// `ChatSwipeToReply` for why it is exclusive with every other gesture here. @@ -278,8 +286,13 @@ public final class ChatViewController: UICollectionViewController { swipeToReply.recognizer.isEnabled = false swipeToReply.recognizer.isEnabled = true collectionView.reloadData() - performInitialScrollIfNeeded() - performPendingScrollIfLanded() + // A jump that was waiting on this update owns where the transcript lands, so it runs + // instead of the opening scroll-to-bottom rather than after it: that scroll queues a + // re-anchor for the next runloop turn, which would pull the transcript off the message + // a beat after arriving on it. + if !performPendingScrollIfLanded() { + performInitialScrollIfNeeded() + } return } @@ -400,6 +413,7 @@ public final class ChatViewController: UICollectionViewController { /// hooks, so the wave restarts every time the row is (re)inserted. public override func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { (cell as? ChatTypingIndicatorCell)?.startAnimating() + reattachAttention(to: cell, at: indexPath) } public override func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { @@ -433,25 +447,72 @@ public final class ChatViewController: UICollectionViewController { scrollToRow(id: id, animated: true) } - /// Performs a deferred jump once the update that brought the row in has been applied. - private func performPendingScrollIfLanded() { + /// Performs a deferred jump once the update that brought the row in has been applied, reporting + /// whether one ran. + /// + /// A jump that runs also consumes the opening scroll-to-bottom: the two want the transcript in + /// different places, and the message the user asked for wins. + @discardableResult + private func performPendingScrollIfLanded() -> Bool { guard let target = pendingScrollTargetID, - items.contains(where: { $0.differenceIdentifier.hasSuffix(":\(target)") }) else { return } + items.contains(where: { $0.differenceIdentifier.hasSuffix(":\(target)") }) else { return false } pendingScrollTargetID = nil + needsInitialScroll = false scrollToRow(id: target, animated: false) + return true } - /// Centers a row that is already in `items`. + /// 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. private func scrollToRow(id: String, animated: Bool) { guard let index = items.firstIndex(where: { $0.differenceIdentifier.hasSuffix(":\(id)") }) else { return } let indexPath = IndexPath(item: index, section: 0) + positionClaim += 1 guard animated else { collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false) + flashAttention(forStableID: id) return } ChatMotion.scroll.animate { self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false) } + // Alongside the scroll rather than after it: the flash's rise is shorter than the scroll's + // settle, so the row is already lit when it arrives and there is no beat where the transcript + // has stopped on a message that looks like every other one. + flashAttention(forStableID: id) + } + + /// Flashes the row with `stableID`, and holds it as the attention target for as long as the + /// flash runs so `willDisplay` can re-attach it. + /// + /// The scroll before it only moves the offset — the row it lands on has no cell until the next + /// layout — so the layout is forced here rather than the flash being deferred a runloop turn. + /// Deferring doesn't work: the queued attempt can drain before any layout pass has run, and the + /// flash has to start alongside the scroll rather than after it. That forced pass displays the + /// arriving rows, so `willDisplay` lights the target; a row already on screen gets no + /// `willDisplay`, which is what the direct attach below covers. + private func flashAttention(forStableID stableID: String) { + let now = CACurrentMediaTime() + attention = (id: stableID, startedAt: now) + collectionView.layoutIfNeeded() + bubbleCell(forStableID: stableID)?.flashAttention(startedAt: now) + } + + /// Lights a cell that has just been displayed if it carries the row a jump is pointing at. + /// + /// A recycled cell loses its `CAAnimation`s, and a jump lands right when the transcript is + /// re-dequeueing rows around the page that brought the target in — so without this the flash is + /// dropped within a frame of starting. Re-attaching from the original start time joins the flash + /// in progress, so a row displayed twice doesn't play it twice as long. + private func reattachAttention(to cell: UICollectionViewCell, at indexPath: IndexPath) { + guard let attention, + items.indices.contains(indexPath.item), + items[indexPath.item].id == attention.id else { return } + guard CACurrentMediaTime() - attention.startedAt < ChatMotion.attentionDuration else { + self.attention = nil + return + } + (cell as? BubbleCarrying)?.flashAttention(startedAt: attention.startedAt) } /// Scroll to the newest message by re-anchoring the layout to the last item's bottom edge. @@ -463,12 +524,14 @@ public final class ChatViewController: UICollectionViewController { indexPath: IndexPath(item: items.count - 1, section: 0), edge: .bottom ) + let claim = positionClaim guard animated else { chatLayout.restoreContentOffset(with: snapshot) // The first restore positions by the estimate; once the bottom cells self-size, re-anchor // so a tall last cell (cash card, long message) sits fully above the bar, not short. DispatchQueue.main.async { [weak self] in - self?.chatLayout.restoreContentOffset(with: snapshot) + guard let self, positionClaim == claim else { return } + chatLayout.restoreContentOffset(with: snapshot) } return } @@ -480,6 +543,7 @@ public final class ChatViewController: UICollectionViewController { self.collectionView.setContentOffset(CGPoint(x: 0, y: target), animated: false) } completion: { _ in // Lock to the exact bottom edge once the animation lands (the estimate may have moved). + guard self.positionClaim == claim else { return } self.chatLayout.restoreContentOffset(with: snapshot) } } @@ -876,10 +940,12 @@ extension ChatMessage { } /// A message cell that can supply the view + shape for the context-menu lift preview, so the lift is -/// clipped to the bubble rather than the full side-hugging cell. +/// clipped to the bubble rather than the full side-hugging cell, and can flash its own ground when +/// the transcript jumps to it. protocol BubbleCarrying { var liftPreviewView: UIView { get } var liftPreviewMaskingPath: UIBezierPath? { get } + func flashAttention(startedAt start: CFTimeInterval) } #endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift index 5e882a7a9..8c6897803 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift @@ -113,6 +113,12 @@ public final class LinkableBubbleView: UIView { /// The bubble's shape, for clipping the context-menu lift preview. var maskingPath: UIBezierPath { background.maskingPath } + /// Flashes the bubble's ground to point the eye at this message after a jump. + func flashAttention(startedAt start: CFTimeInterval = CACurrentMediaTime()) { background.flashAttention(startedAt: start) } + + /// Whether this bubble is currently flashing. + var isFlashingAttention: Bool { background.isFlashingAttention } + func prepareForReuse() { textView.resignFirstResponder() quotePanel.onTap = nil