From 251f507163d904fe390baf952931d1586b26021d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 4 Nov 2026 15:57:40 -0600 Subject: [PATCH] fix(chat): fade in a cash card's counterpart and token icon when they arrive late A cash card's counterpart line and token icon can land after the card is drawn (the peer's name resolves, the icon's URL arrives). The transcript's reflow spring already grows the card to fit them, but the lines themselves popped in at full opacity. They now fade in over ChatMotion.reflow's duration, as the Send Back row and footer do since #1227. A line known at first draw, a redraw with nothing new, and a recycled cell draw without animating. The fade is a CABasicAnimation on opacity: a CATransition is filed under Core Animation's own `transition` key, so it could not be told apart. Spec: orchestrator #117. --- .../Chat/ChatCashCardCellTests.swift | 81 +++++++++++++++++++ .../FlipcashUI/Chat/ChatCashCardCell.swift | 45 +++++++++-- 2 files changed, 118 insertions(+), 8 deletions(-) diff --git a/FlipcashTests/Chat/ChatCashCardCellTests.swift b/FlipcashTests/Chat/ChatCashCardCellTests.swift index e7805c7ff..0dd1bf057 100644 --- a/FlipcashTests/Chat/ChatCashCardCellTests.swift +++ b/FlipcashTests/Chat/ChatCashCardCellTests.swift @@ -136,6 +136,87 @@ struct ChatCashCardCellTests { #expect(ChatCashCardCell.sendBackArrivesLate(drawn: ("3", .sending), id: "3", state: .sentBack)) } + // MARK: - Lines that arrive after the card is drawn (send-back spec, orchestrator #117) + + private func card(id: String = "5", counterpart: String?, iconURL: URL? = nil, sendBack: ChatCashContent.SendBack? = nil) -> ChatMessage { + ChatMessage(id: id, content: .cash(ChatCashContent( + amount: "$5.00", token: "Cash", iconURL: iconURL, + lines: counterpart.map { .init(caption: "Noah Parker sent", counterpart: $0) }, + sendBack: sendBack + )), sender: .other) + } + + private static let icon = URL(fileURLWithPath: "/nonexistent/icon.png") + + private func counterpartLabel(in cell: ChatCashCardCell) throws -> UILabel { + try #require(labels(in: cell.contentView).first { $0.text?.hasPrefix("from") == true || $0.text?.hasPrefix("to ") == true }) + } + + private func coinIcon(in cell: ChatCashCardCell) throws -> UIImageView { + func images(in view: UIView) -> [UIImageView] { + view.subviews.flatMap { ($0 as? UIImageView).map { [$0] } ?? images(in: $0) } + } + return try #require(images(in: cell.contentView).first { $0.layer.cornerRadius == 10 }) + } + + private func fades(_ view: UIView) -> Bool { + view.layer.animation(forKey: ChatCashCardCell.lateArrivalKey) != nil + } + + @Test("A counterpart line known at first draw draws without animating") + func counterpart_firstDraw_doesNotAnimate() throws { + let cell = ChatCashCardCell(frame: .zero) + cell.configure(with: card(counterpart: "from Noah Parker"), maxWidth: 250) + #expect(!fades(try counterpartLabel(in: cell))) + } + + @Test("A counterpart line that arrives on a drawn card fades in", arguments: [nil, "from Someone"]) + func counterpart_lateArrival_animates(before: String?) throws { + let cell = ChatCashCardCell(frame: .zero) + cell.configure(with: card(counterpart: before), maxWidth: 250) + cell.configure(with: card(counterpart: "from Noah Parker"), maxWidth: 250) + #expect(fades(try counterpartLabel(in: cell))) + } + + @Test("A counterpart line that arrives on a drawn card grows its height") + func counterpart_lateArrival_growsCard() { + let cell = ChatCashCardCell(frame: .zero) + let before = measure(card(counterpart: nil), in: cell) + #expect(measure(card(counterpart: "from Noah Parker"), in: cell) > before) + } + + @Test("A token icon known at first draw draws without animating") + func icon_firstDraw_doesNotAnimate() throws { + let cell = ChatCashCardCell(frame: .zero) + cell.configure(with: card(counterpart: "from Noah Parker", iconURL: Self.icon), maxWidth: 250) + #expect(!fades(try coinIcon(in: cell))) + } + + @Test("A token icon that arrives on a drawn card fades in") + func icon_lateArrival_animates() throws { + let cell = ChatCashCardCell(frame: .zero) + cell.configure(with: card(counterpart: "from Noah Parker"), maxWidth: 250) + cell.configure(with: card(counterpart: "from Noah Parker", iconURL: Self.icon), maxWidth: 250) + #expect(fades(try coinIcon(in: cell))) + } + + @Test("A redraw with nothing new, or a recycled cell, animates nothing") + func unchangedOrReused_doesNotAnimate() throws { + let cell = ChatCashCardCell(frame: .zero) + let full = card(counterpart: "from Noah Parker", iconURL: Self.icon) + cell.configure(with: full, maxWidth: 250) + cell.configure(with: full, maxWidth: 250) + #expect(!fades(try counterpartLabel(in: cell))) + #expect(!fades(try coinIcon(in: cell))) + + let reused = ChatCashCardCell(frame: .zero) + reused.configure(with: card(id: "6", counterpart: nil), maxWidth: 250) + reused.prepareForReuse() + reused.configure(with: full, maxWidth: 250) + #expect(!fades(try counterpartLabel(in: reused))) + #expect(!fades(try coinIcon(in: reused))) + } + @Test("A cell recycled from an offered card to a plain one drops the row") func reusedCell_sendBackToPlain_measuresFresh() { let cell = ChatCashCardCell(frame: .zero) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift index 75bb13352..543109fab 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift @@ -48,9 +48,17 @@ public final class ChatCashCardCell: ChatColumnCell { private let counterpartLabel = UILabel() private let centerStack = UIStackView() private let sendBackRow = SendBackRowView() - /// The message and Send Back state this cell last drew, so a row that arrives on a card already - /// on screen can animate in while one known at first draw does not. - private var drawnSendBack: (id: String, state: ChatCashContent.SendBack?)? + /// What this cell last drew, so a line that arrives on a card already on screen (the Send Back + /// row, the counterpart, the token icon) can animate in while one known at first draw does not. + private var drawn: Drawn? + private struct Drawn { + let id: String + let sendBack: ChatCashContent.SendBack? + let counterpart: String? + let iconURL: URL? + } + /// The layer animation key a late-arriving line's fade runs under; read by tests. + static let lateArrivalKey = "cashCardLateArrival" /// Whether the last configure faded the Send Back row in; read by tests. private(set) var didAnimateSendBackIn = false /// The counterpart line's gap to the card's foot, or to the Send Back row when one is drawn. @@ -189,7 +197,7 @@ public final class ChatCashCardCell: ChatColumnCell { coinIcon.kf.cancelDownloadTask() coinIcon.image = nil reactionRow.prepareForReuse() - drawnSendBack = nil + drawn = nil } /// - Parameter maxWidth: the card's width, in points. The owner derives it from the collection @@ -208,12 +216,21 @@ public final class ChatCashCardCell: ChatColumnCell { // The total sits tight under the amount, the closest pair of lines on the card. centerStack.setCustomSpacing(total == nil ? Self.tokenSpacing : 0, after: amountLabel) + // A line that arrives on a card already drawn fades in while the transcript's reflow spring + // grows the card to fit it (send-back spec); one known at first draw appears as is. + let previous = drawn?.id == message.id ? drawn : nil + drawn = Drawn(id: message.id, sendBack: cash.sendBack, counterpart: cash.lines?.counterpart, iconURL: cash.iconURL) + tokenLabel.text = cash.token coinIcon.isHidden = cash.iconURL == nil coinIcon.kf.setImage(with: cash.iconURL) + if let previous, previous.iconURL == nil, cash.iconURL != nil { Self.fadeIn(coinIcon) } counterpartLabel.text = cash.lines?.counterpart counterpartLabel.isHidden = cash.lines == nil - applySendBack(cash.sendBack, id: message.id) + if let previous, let counterpart = cash.lines?.counterpart, previous.counterpart != counterpart { + Self.fadeIn(counterpartLabel) + } + applySendBack(cash.sendBack, previous: previous) card.apply( fill: BubbleBackgroundView.fill(isFromSelf: message.sender == .me), @@ -235,6 +252,17 @@ public final class ChatCashCardCell: ChatColumnCell { card.alpha = isHighlighted ? 0.6 : 1 } + /// Fades `view`'s new content in over the reflow spring's duration, under `lateArrivalKey`. + private static func fadeIn(_ view: UIView) { + // An opacity animation, not a CATransition: Core Animation files a transition under its own + // `transition` key, whatever key it is given. + let fade = CABasicAnimation(keyPath: "opacity") + fade.fromValue = 0 + fade.toValue = 1 + fade.duration = ChatMotion.reflow.duration + view.layer.add(fade, forKey: lateArrivalKey) + } + /// Whether `state` arrives on a card this cell has already drawn for message `id`: the same /// message, a different Send Back state, and a row to show. A first draw, or a cell reused for /// another message, has nothing to animate from. @@ -253,9 +281,10 @@ public final class ChatCashCardCell: ChatColumnCell { /// A row or footer that arrives on a card already drawn (the status check resolving, a return /// landing) fades in while the transcript's reflow spring grows the card to fit it. One known /// when the card is first drawn appears with no animation. - private func applySendBack(_ state: ChatCashContent.SendBack?, id: String) { - let arrivesLate = Self.sendBackArrivesLate(drawn: drawnSendBack, id: id, state: state) - drawnSendBack = (id, state) + private func applySendBack(_ state: ChatCashContent.SendBack?, previous: Drawn?) { + let arrivesLate = previous.map { previous in + Self.sendBackArrivesLate(drawn: (previous.id, previous.sendBack), id: previous.id, state: state) + } ?? false didAnimateSendBackIn = arrivesLate sendBackRow.isHidden = state == nil if let state { sendBackRow.apply(state) }