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
81 changes: 81 additions & 0 deletions FlipcashTests/Chat/ChatCashCardCellTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 37 additions & 8 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -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.
Expand All @@ -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) }
Expand Down
Loading