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
8 changes: 7 additions & 1 deletion Flipcash/Core/Screens/Conversation/ComposerModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,13 @@ final class ComposerModel {

/// Empties the field after a successful send.
func clear() {
mode = .new
// Only when it actually changes. `@Observable` fires on assignment without comparing, and
// the chat screen's body reads `isEditing` and `editingStableID` — both derived from this —
// so writing `.new` over `.new` on every send rebuilt the whole screen and re-ran
// `updateUIViewController` at the frame the insertion animation started.
if mode != .new {
mode = .new
}
originalText = ""
stashedDraft = ""
draft = ""
Expand Down
10 changes: 6 additions & 4 deletions Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,12 @@ struct ConversationBottomBar: View {
var isTipDm: Bool = false

var body: some View {
// Top-aligned: the field is the side that grows (a multiline draft) and it grows upward off
// a pinned bar bottom, so anchoring the control beside it to the field's top edge keeps the
// two reading as one row instead of leaving the button stranded low.
let content = HStack(alignment: .top, spacing: 10) {
// Bottom-aligned, against the bar's own pinned bottom: the field is the side that grows, and
// top-aligning the control beside it made the control travel with every line the draft
// gained or lost. Nothing animates that travel — the bar's springs key on `chatExists` and
// `isEditing`, neither of which moves during a send — so it snapped while the bar's height
// sprang underneath it.
let content = HStack(alignment: .bottom, spacing: 10) {
// An edit takes over the bar: the leading control becomes the way out of it and Send
// Cash steps aside until it resolves, the way WhatsApp hides its accessory controls.
if composer.isEditing {
Expand Down
59 changes: 42 additions & 17 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,20 @@ import FlipcashCore
/// column, hugging the leading or trailing edge by sender. A subclass builds its content view (a
/// bubble or a card), hands it to `installColumn(content:)` from `init`, then calls `updateColumn(for:)`
/// from its own `configure`. The receipt collapses out of the column when the message carries none.
///
/// The column spans the full row and the stack's `alignment` does the hugging, rather than the column
/// hugging its content with one floating edge. A floating edge made the column's width the receipt's
/// for short messages, so clearing the receipt moved the column sideways by the difference and only
/// left the bubble still because the bubble's offset inside the stack moved the other way by exactly
/// as much. That cancellation holds on final values; mid-animation it is three separate layer
/// animations, and any one of them off the others' curve slides the bubble sideways.
public class ChatColumnCell: UICollectionViewCell {

private let receipt = ChatReceiptView()
private let column = UIStackView()
private var leadingConstraint: NSLayoutConstraint!
private var trailingConstraint: NSLayoutConstraint!
/// The subclass's content view, kept so the retry recognizer can restrict itself to the visible
/// row — the column spans the full width, so its own bounds are not the hit area.
private var content: UIView?

/// Fired when the user taps a failed row to retry; the argument is the message's stable id.
var onRetry: ((String) -> Void)?
Expand All @@ -32,10 +40,11 @@ public class ChatColumnCell: UICollectionViewCell {
/// directly, so it never replays this cell's prior line (a reused failed cell flashing red).
private var currentMessageID: String?

/// Stacks `content` above the receipt and pins the column into the contentView, pinning top and
/// bottom so the cell self-sizes to the content plus the receipt line. Call once, from the
/// subclass's `init`, after the content view exists.
/// Stacks `content` above the receipt and pins the column to all four edges of the contentView,
/// so the cell self-sizes to the content plus the receipt line. Call once, from the subclass's
/// `init`, after the content view exists.
func installColumn(content: UIView) {
self.content = content
column.axis = .vertical
column.spacing = 4
column.addArrangedSubview(content)
Expand All @@ -45,21 +54,37 @@ public class ChatColumnCell: UICollectionViewCell {

// The whole bubble + status line is the retry target (a generous hit area vs. the thin receipt
// line). The recognizer is enabled only for a failed row (see updateColumn), so non-failed
// bubbles don't consume taps and keep their long-press copy menu.
// bubbles don't consume taps and keep their long-press copy menu. It sits on the column rather
// than the content view because a failed link row disables its bubble's interaction to stop
// URL taps, which would otherwise take the retry with it.
let tap = UITapGestureRecognizer(target: self, action: #selector(retryTapped))
tap.isEnabled = false
tap.delegate = self
column.addGestureRecognizer(tap)
retryTap = tap

leadingConstraint = column.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12)
trailingConstraint = column.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12)

NSLayoutConstraint.activate([
column.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
column.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
column.topAnchor.constraint(equalTo: contentView.topAnchor),
column.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
}

/// Self-sizes on height alone, at the width the layout asked for. The column spans the row, so
/// the default implementation's compressed horizontal fit measures the cell at its content width
/// instead — a width the layout then discards, having already forced the row to full width.
public override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
let width = layoutAttributes.frame.width
let height = contentView.systemLayoutSizeFitting(
CGSize(width: width, height: 0),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
).height
layoutAttributes.frame.size = CGSize(width: width, height: height)
return layoutAttributes
}

public override func prepareForReuse() {
super.prepareForReuse()
currentMessageID = nil
Expand All @@ -85,21 +110,21 @@ public class ChatColumnCell: UICollectionViewCell {
retryTap?.isEnabled = message.isFailed
receipt.setReceipt(message.receipt, animated: isInPlaceUpdate && window != nil)
column.alignment = message.sender == .me ? .trailing : .leading
applyAlignment(isFromSelf: message.sender == .me)
}

@objc private func retryTapped() {
guard let retryID else { return }
onRetry?(retryID)
}
}

extension ChatColumnCell: UIGestureRecognizerDelegate {

/// Exactly one horizontal edge is pinned, so the column hugs its sender's side and the opposite
/// edge floats. Both edges are deactivated before the wanted one is activated: a recycled cell
/// still carries its prior encapsulated layout width, so momentarily pinning both edges
/// over-constrains it and trips Auto Layout's unsatisfiable-constraints check.
private func applyAlignment(isFromSelf: Bool) {
NSLayoutConstraint.deactivate([leadingConstraint, trailingConstraint])
(isFromSelf ? trailingConstraint : leadingConstraint).isActive = true
/// Keeps retry to the row the user can see. The column spans the full width so its frame doesn't
/// move when the receipt collapses, which leaves the empty half of the row inside its bounds.
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
let point = touch.location(in: column)
return (content.map { $0.frame.contains(point) } ?? false) || (!receipt.isHidden && receipt.frame.contains(point))
}
}
#endif
12 changes: 7 additions & 5 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,14 @@ public nonisolated enum ChatMotion {
public static let insertion = ChatSpring(duration: 0.23, bounce: 0.27)
/// The list settling at the bottom after content is appended.
public static let scroll = ChatSpring(duration: 0.30, bounce: 0.12)
/// The scroll that accompanies the keyboard.
/// The transcript following the bottom chrome — the keyboard, or the bar resizing around a
/// multiline draft.
///
/// Nothing calls this today. The transcript inherits the system keyboard curve directly (see
/// `ChatViewController.scrollViewDidChangeAdjustedContentInset`), which is the same intent this
/// spring's zero bounce encodes — any overshoot would fight the keyboard. It stays defined so
/// the vocabulary is complete and the spec's eight springs are all covered by the physics test.
/// The keyboard case inherits the system curve directly (see
/// `ChatViewController.scrollViewDidChangeAdjustedContentInset`) rather than this spring, which
/// is the same intent the zero bounce encodes: any overshoot would fight the keyboard. The bar
/// case has no curve to inherit, so `ChatScreenViewController.setBarHeight` uses this one — and
/// wants the same stillness, since a send runs it alongside `insertion` and `scroll`.
public static let keyboardScroll = ChatSpring(duration: 0.30, bounce: 0)
/// The "Delivered" line appearing under a sent bubble. Slow and gentle: it arrives after the
/// message has landed and shouldn't compete with it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,14 @@ public final class ChatScreenViewController: UIViewController {
// Lay out inside the animation so the transcript's inset change — `viewDidLayoutSubviews`
// feeds the new bar height to `setBottomInset` — rides the same curve and the content
// scrolls up with the bar, instead of snapping on whatever layout pass happens to run next.
UIView.animate(springDuration: ChatMotion.swap.duration, bounce: ChatMotion.swap.bounce) {
//
// The transcript's own spring, not the bar's: a height change moves the content, and a send
// collapses a multiline field at the same moment the insertion and the settle-to-bottom are
// running. `swap` is the bounciest spring in the vocabulary bar one, and three curves
// overshooting the same pixels by different amounts is what read as the bar and the
// transcript coming apart. Zero bounce keeps this one out of the other two's way.
let spring = ChatMotion.keyboardScroll
UIView.animate(springDuration: spring.duration, bounce: spring.bounce) {
self.view.layoutIfNeeded()
}
}
Expand Down
29 changes: 21 additions & 8 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,11 @@ public final class ChatViewController: UICollectionViewController {
/// A transcript pushed while the menu was up, applied once it closes (so an arriving message can't
/// reflow the content mid-preview). Mirrors ChatLayout deferring updates while `.showingPreview`.
private var deferredItems: [ChatItem]?
/// A bottom inset requested while the menu had the inset frozen, applied once it closes. The bar
/// can grow from a menu action — choosing Edit opens the editing banner — and that request lands
/// during the freeze, so without holding it the transcript keeps the shorter bar's inset and the
/// banner covers the newest rows.
/// A bottom inset requested while the inset was not the caller's to change — the menu had it
/// frozen, or a batch update was in flight — applied as soon as it is. The bar can grow from a
/// menu action (choosing Edit opens the editing banner) and can shrink from a send (a multiline
/// draft collapsing), and both land inside one of those windows; without holding the request the
/// transcript keeps the old bar's inset until some later layout pass corrects it.
private var pendingBottomInset: CGFloat?

/// Called as a context menu is presented and again as it starts to dismiss, each carrying the
Expand Down Expand Up @@ -253,7 +254,12 @@ public final class ChatViewController: UICollectionViewController {
}
},
completion: { [weak self] _ in
self?.isUpdating = false
guard let self else { return }
isUpdating = false
if let inset = pendingBottomInset {
pendingBottomInset = nil
setBottomInset(inset)
}
},
setData: { [weak self] data in
self?.items = data
Expand Down Expand Up @@ -423,10 +429,17 @@ public final class ChatViewController: UICollectionViewController {
return
}
// Never change the inset mid-batch-update: ChatLayout can't account for an inset change
// during `performBatchUpdates`, which is what made an append (a send) overshoot. The next
// layout pass after the update re-applies it.
// during `performBatchUpdates`, which is what made an append (a send) overshoot. Hold it for
// the update's completion rather than waiting for whatever layout pass happens to run next —
// a send that also collapses a multiline field lands the bar's new height inside the update,
// and dropping the request there left the bar animating to a height the transcript only
// matched a pass later, as a snap.
guard !isUpdating else {
pendingBottomInset = inset
return
}
let target = inset + Self.bottomContentPadding
guard isViewLoaded, !isUpdating, abs(collectionView.contentInset.bottom - target) > 0.5 else { return }
guard isViewLoaded, abs(collectionView.contentInset.bottom - target) > 0.5 else { return }
let snapshot = chatLayout.getContentOffsetSnapshot(from: .bottom)
isAdjustingBottomInset = true // suppress the delegate re-entry from the inset write below
collectionView.contentInset.bottom = target
Expand Down
Loading