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
57 changes: 57 additions & 0 deletions FlipcashTests/Chat/ChatViewControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
2 changes: 2 additions & 0 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
9 changes: 9 additions & 0 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 74 additions & 8 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand All @@ -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
}
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading