diff --git a/Flipcash/Core/Screens/Conversation/ComposerModel.swift b/Flipcash/Core/Screens/Conversation/ComposerModel.swift index 3886093b5..b5de7b178 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerModel.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerModel.swift @@ -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 = "" diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 31c82fedd..ff101fe97 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -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 { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift index 5a97ebaaa..de981163a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift @@ -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)? @@ -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) @@ -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 @@ -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 diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift index b17b7eb34..49407771f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift @@ -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. diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 174b46021..0b34622f4 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -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() } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 46e9b1cdb..45c490c7a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -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 @@ -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 @@ -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