diff --git a/CLAUDE.md b/CLAUDE.md index 39d59e81..2faa5d0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -554,6 +554,7 @@ Types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore` | Calling `router.present(.x)` next to a viewmodel mutator that may *block* the flow | A viewmodel that surfaces a blocking error via `session.dialogItem` (e.g. `GiveViewModel.showNoBalanceError`) does not stop the router — `DialogWindow` renders the dialog above the sheet, but the sheet is still presented underneath and reappears once the dialog is dismissed. Gate the router on the precondition: expose `attemptPresent() -> Bool` on the viewmodel and write `if vm.attemptPresent() { router.present(.x) }`. Putting the check inside an `isPresented` `didSet` is not enough — the router call still runs unconditionally on the next line. | | Parsing keypad-emitted amounts with `Decimal(string:)` or `NumberFormatter.decimal(from:)` | `KeyPadView`'s decimal key inserts `AmountValidator.localizedDecimalSeparator`, so on comma-decimal locales the bound string contains ",". `Decimal(string:)` stops at the comma and silently drops the fraction; `NumberFormatter.decimal(from:)` only parses the device locale's format. **Parse keypad strings with `AmountValidator`** (in FlipcashCore's Validation family) — it normalizes the locale separator before parsing. `NumberFormatter.decimal(from:)` remains appropriate for currency-formatted strings (already through a formatter, locale-correct). | | Injecting shared DI via a custom keyPath `@Environment(\.key)` with a trapping default | SwiftUI resolves keyPath env **eagerly** during dynamic-list/transition `DynamicProperty` updates (against a placeholder environment), so a `fatalError`/`preconditionFailure` default fires and crashes at launch (` was not injected`). Inject shared DI as **type-based `@Environment(Type.self)` on an `@Observable`** — the trap is deferred to body access, so it survives the eager pass. That's why `Container`/`SessionContainer` are `@Observable`. Safe-value `@Entry` keyPath defaults (e.g. `nestedSheetDepth = 0`) are unaffected — the hazard is specifically a *crashing* default. | +| Touching the chat transcript's diff/batch pipeline without the fence | ChatLayout's `restoreContentOffset` answers attribute queries with nil while it re-anchors; if that forced layout pass overlaps animated layout work (a settling batch spring, an inset write inside an animation context), UIKit throws `NSInternalInconsistencyException: missing final attributes for cell`. Every animated transaction in `ChatViewController` brackets itself on `ChatAnimationFence`, and every mutation that must not overlap one (transcript push, `setBottomInset`, offset restores, `freezeInset`/`restoreInset`) defers through `fence.whenIdle` and applies insets via the upstream recipe (`performWithoutAnimation` + empty-batch interrupt + write inside `performBatchUpdates` + restore in the same transaction). Also: **never set transforms on `ChatLayoutAttributes`** — the layout round-trips attribute frames through its keep-at-bottom compensation, so a scale gets baked into stored frames; entrance motion goes on the *cell* in `willDisplay` (`playEntranceIfNeeded`). Contract pinned by `ChatAnimationFenceTests`, the fence section of `ChatViewControllerTests`, and the baseline-attributes tests in `ChatMotionTests`. | --- diff --git a/FlipcashTests/Chat/ChatAnimationFenceTests.swift b/FlipcashTests/Chat/ChatAnimationFenceTests.swift new file mode 100644 index 00000000..0a0132a0 --- /dev/null +++ b/FlipcashTests/Chat/ChatAnimationFenceTests.swift @@ -0,0 +1,80 @@ +// +// ChatAnimationFenceTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +@testable import FlipcashUI + +/// The fence is the transcript's crash guard: ChatLayout's `restoreContentOffset` answers +/// attribute queries with nil while it re-anchors, so inset writes, offset restores, and +/// transcript pushes must never overlap an animated transaction — UIKit throws +/// `NSInternalInconsistencyException: missing final attributes for cell` when they collide. +/// These tests pin the serialization contract every chat-transcript mutation relies on. +@MainActor +@Suite("Chat animation fence") +struct ChatAnimationFenceTests { + + @Test("Work runs immediately while idle") + func idle_runsImmediately() { + let fence = ChatAnimationFence() + var ran = false + fence.whenIdle { ran = true } + #expect(ran) + } + + @Test("Work defers while any transaction is active and replays when the last settles") + func active_defersUntilLastEnd() { + let fence = ChatAnimationFence() + fence.begin() + fence.begin() + var ran = false + fence.whenIdle { ran = true } + fence.end() + #expect(!ran, "one transaction is still in flight") + fence.end() + #expect(ran) + } + + @Test("Deferred work replays in registration order") + func drain_replaysInOrder() { + let fence = ChatAnimationFence() + fence.begin() + var order: [Int] = [] + fence.whenIdle { order.append(1) } + fence.whenIdle { order.append(2) } + fence.end() + #expect(order == [1, 2]) + } + + @Test("A replay that begins a new transaction re-defers the work queued behind it") + func drain_replayBeginningTransaction_reDefersRemainder() { + // The crash shape: a held transcript push replays and starts a new batch; a held inset + // write queued behind it must wait for that batch too — never run inside it. + let fence = ChatAnimationFence() + fence.begin() + var insetApplied = false + fence.whenIdle { fence.begin() } // the replayed push opens its own transaction + fence.whenIdle { insetApplied = true } // the inset write queued behind it + fence.end() + #expect(!insetApplied, "the remainder must wait behind the replay's transaction") + fence.end() + #expect(insetApplied) + } + + @Test("Work registered during a drain joins the queue instead of jumping it") + func drain_nestedWhenIdle_runsAfterCurrentQueue() { + let fence = ChatAnimationFence() + fence.begin() + var order: [Int] = [] + fence.whenIdle { + order.append(1) + fence.whenIdle { order.append(3) } // registered mid-drain + } + fence.whenIdle { order.append(2) } + fence.end() + #expect(order == [1, 2, 3]) + } +} diff --git a/FlipcashTests/Chat/ChatChangesetFlatteningTests.swift b/FlipcashTests/Chat/ChatChangesetFlatteningTests.swift index 3c368299..4aeac57f 100644 --- a/FlipcashTests/Chat/ChatChangesetFlatteningTests.swift +++ b/FlipcashTests/Chat/ChatChangesetFlatteningTests.swift @@ -19,31 +19,14 @@ import FlipcashCore @Suite("Chat changeset flattening") struct ChatChangesetFlatteningTests { - private func message( - _ id: String, - sender: ChatMessage.Sender = .me, - continuedByNext: Bool = false, - continuationFromPrevious: Bool = false, - receipt: String? = nil - ) -> ChatItem { - .message(ChatMessage( - id: id, - text: "text-\(id)", - sender: sender, - isContinuationFromPrevious: continuationFromPrevious, - isContinuedByNext: continuedByNext, - receipt: receipt - )) - } - @Test("A send (previous-row update + insert) flattens to a single changeset") - func updateAndInsert_flattensToOne() { + func updateAndInsert_flattensToOne() throws { // A new own message migrates the receipt off the previous row and flips its grouping — // an update — while the new row is an insert. DifferenceKit stages these separately. - let before = [message("a", receipt: "Delivered")] - let after = [ - message("a", continuedByNext: true), - message("b", continuationFromPrevious: true, receipt: "Delivered"), + let before: [ChatItem] = [.text("a", receipt: "Delivered")] + let after: [ChatItem] = [ + .text("a", continuedByNext: true), + .text("b", continuationFromPrevious: true, receipt: "Delivered"), ] let staged = StagedChangeset(source: before, target: after) @@ -51,7 +34,7 @@ struct ChatChangesetFlatteningTests { let flattened = staged.flattenIfPossible() #expect(flattened.count == 1) - guard let merged = flattened.first else { return } + let merged = try #require(flattened.first) #expect(merged.elementUpdated == [ElementPath(element: 0, section: 0)]) #expect(merged.elementInserted == [ElementPath(element: 1, section: 0)]) #expect(merged.elementDeleted.isEmpty) @@ -60,13 +43,13 @@ struct ChatChangesetFlatteningTests { } @Test("An arrival while typing (update + delete + insert) flattens to a single changeset") - func updateDeleteAndInsert_flattensToOne() { + func updateDeleteAndInsert_flattensToOne() throws { // The typing indicator clears (delete), the reply lands (insert), and the previous row's // grouping flips (update) — three DifferenceKit stages in one push. - let before = [message("a", sender: .other), .typingIndicator] - let after = [ - message("a", sender: .other, continuedByNext: true), - message("b", sender: .other, continuationFromPrevious: true), + let before: [ChatItem] = [.text("a", sender: .other), .typingIndicator] + let after: [ChatItem] = [ + .text("a", sender: .other, continuedByNext: true), + .text("b", sender: .other, continuationFromPrevious: true), ] let staged = StagedChangeset(source: before, target: after) @@ -74,7 +57,7 @@ struct ChatChangesetFlatteningTests { let flattened = staged.flattenIfPossible() #expect(flattened.count == 1) - guard let merged = flattened.first else { return } + let merged = try #require(flattened.first) #expect(merged.elementUpdated == [ElementPath(element: 0, section: 0)]) #expect(merged.elementDeleted == [ElementPath(element: 1, section: 0)]) #expect(merged.elementInserted == [ElementPath(element: 1, section: 0)]) @@ -83,14 +66,14 @@ struct ChatChangesetFlatteningTests { } @Test("A pure delete + insert pair still flattens to a single changeset") - func deleteAndInsert_stillFlattensToOne() { + func deleteAndInsert_stillFlattensToOne() throws { // The pre-existing behavior (typing indicator swaps for a message with no other change). - let before = [message("a", sender: .other), .typingIndicator] - let after = [message("a", sender: .other), message("b", sender: .other)] + let before: [ChatItem] = [.text("a", sender: .other), .typingIndicator] + let after: [ChatItem] = [.text("a", sender: .other), .text("b", sender: .other)] let flattened = StagedChangeset(source: before, target: after).flattenIfPossible() #expect(flattened.count == 1) - guard let merged = flattened.first else { return } + let merged = try #require(flattened.first) #expect(merged.elementDeleted == [ElementPath(element: 1, section: 0)]) #expect(merged.elementInserted == [ElementPath(element: 1, section: 0)]) #expect(merged.data == after) @@ -100,8 +83,8 @@ struct ChatChangesetFlatteningTests { func moves_areNotFlattened() { // A move's source index is relative to the post-delete stage, not the original source, so // merging would corrupt indices. Reorders never happen in a transcript; keep them staged. - let before = [message("a"), message("b")] - let after = [message("b"), .message(ChatMessage(id: "a", text: "edited", sender: .me))] + let before: [ChatItem] = [.text("a"), .text("b")] + let after: [ChatItem] = [.text("b"), .message(ChatMessage(id: "a", text: "edited", sender: .me))] let staged = StagedChangeset(source: before, target: after) #expect(staged.contains { !$0.elementMoved.isEmpty }, "premise: a reorder diffs to a move") diff --git a/FlipcashTests/Chat/ChatMessageCopyTests.swift b/FlipcashTests/Chat/ChatMessageCopyTests.swift index 91113a03..6b375d3b 100644 --- a/FlipcashTests/Chat/ChatMessageCopyTests.swift +++ b/FlipcashTests/Chat/ChatMessageCopyTests.swift @@ -15,10 +15,7 @@ import FlipcashCore struct ChatMessageCopyTests { private func loadedController(_ items: [ChatItem]) -> ChatViewController { - let controller = ChatViewController() - controller.loadViewIfNeeded() - controller.update(items: items) - return controller + .loaded(items: items) } private func configuration(_ controller: ChatViewController, at index: Int) -> UIContextMenuConfiguration? { diff --git a/FlipcashTests/Chat/ChatMotionTests.swift b/FlipcashTests/Chat/ChatMotionTests.swift new file mode 100644 index 00000000..51e82ffa --- /dev/null +++ b/FlipcashTests/Chat/ChatMotionTests.swift @@ -0,0 +1,184 @@ +// +// ChatMotionTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +import ChatLayout +import FlipcashCore +@testable import FlipcashUI + +/// The transcript's motion language, ported from the design prototype: bubbles scale in from 0.95 +/// anchored at their sender's edge (never slide), receipts and corner morphs animate only on +/// in-place updates of the row they already render. +@MainActor +@Suite("Chat motion") +struct ChatMotionTests { + + // MARK: - Layout attributes stay at the library baseline + + // ChatLayout round-trips attribute frames through its keep-at-bottom compensation: reading + // `.frame` of transformed attributes returns the transformed bounding box, and the offset + // result is written back. Any transform set on insert/delete attributes is therefore baked + // into the layout's stored frames — mis-sized rows, unsatisfiable-constraint spam, and the + // corrupted bookkeeping behind the "missing final attributes" crash. Attributes must stay at + // the library's default pure fade; entrance motion belongs on the cell + // (`ChatViewController.playEntranceIfNeeded`). If one of these fails, someone re-added + // attribute transforms — move the motion to the cell instead. + + private func attributes(width: CGFloat = 390) -> ChatLayoutAttributes { + let attributes = ChatLayoutAttributes(forCellWith: IndexPath(item: 0, section: 0)) + attributes.frame = CGRect(x: 0, y: 0, width: width, height: 56) + return attributes + } + + private var everyRowKind: [ChatItem] { + [ + .message(ChatMessage(id: "a", text: "hi", sender: .me)), + .message(ChatMessage(id: "b", text: "hi", sender: .other)), + .typingIndicator, + .dateSeparator(id: "sep", text: "Today 12:13 PM"), + ] + } + + @Test("Inserted rows keep the library's pure-fade attributes — no transform, ever") + func insertedAttributes_areLibraryBaseline() { + let controller = ChatViewController.loaded(items: everyRowKind, animated: false) + for index in everyRowKind.indices { + let attributes = attributes() + controller.initialLayoutAttributesForInsertedItem( + CollectionViewChatLayout(), at: IndexPath(item: index, section: 0), modifying: attributes, on: .initial + ) + #expect(attributes.alpha == 0) + #expect(attributes.transform == .identity) + } + } + + @Test("Deleted rows keep the library's pure-fade attributes — no transform, ever") + func deletedAttributes_areLibraryBaseline() { + let controller = ChatViewController.loaded(items: everyRowKind, animated: false) + for index in everyRowKind.indices { + let attributes = attributes() + controller.finalLayoutAttributesForDeletedItem( + CollectionViewChatLayout(), at: IndexPath(item: index, section: 0), modifying: attributes + ) + #expect(attributes.alpha == 0) + #expect(attributes.transform == .identity) + } + } + + // MARK: - Cell entrance transform + + // The scale-in runs on the cell (`willDisplay`), composed from this transform. + + @Test("An outgoing bubble's entrance pins its trailing edge") + func entranceOutgoing_pinsTrailingEdge() { + let transform = ChatViewController.entranceTransform( + for: .message(ChatMessage(id: "a", text: "hi", sender: .me)), width: 390 + ) + #expect(abs(transform.a - 0.95) < 0.001) + // Pinning the trailing edge of a 390pt cell scaled to 0.95 means shifting right by half the + // shrink: 390 × 0.05 / 2. + #expect(abs(transform.tx - 9.75) < 0.001) + } + + @Test("An incoming bubble's entrance pins its leading edge") + func entranceIncoming_pinsLeadingEdge() { + let transform = ChatViewController.entranceTransform( + for: .message(ChatMessage(id: "a", text: "hi", sender: .other)), width: 390 + ) + #expect(abs(transform.a - 0.95) < 0.001) + #expect(abs(transform.tx - (-9.75)) < 0.001) + } + + @Test("The typing indicator enters like an incoming bubble") + func entranceTypingIndicator_pinsLeadingEdge() { + let transform = ChatViewController.entranceTransform(for: .typingIndicator, width: 390) + #expect(abs(transform.a - 0.95) < 0.001) + #expect(transform.tx < 0) + } + + @Test("A date separator has no entrance — pure fade") + func entranceDateSeparator_isIdentity() { + let transform = ChatViewController.entranceTransform( + for: .dateSeparator(id: "sep", text: "Today 12:13 PM"), width: 390 + ) + #expect(transform == .identity) + } + + // MARK: - In-place update gate + + @Test("A cell reconfigured for the row it already shows is an in-place update") + func inPlaceGate_sameRowInWindow() { + let cell = ChatMessageCell(frame: CGRect(x: 0, y: 0, width: 390, height: 56)) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.addSubview(cell) + window.makeKeyAndVisible() + + let message = ChatMessage(id: "a", text: "hi", sender: .me) + #expect(!cell.updateColumn(for: message), "first render — not in place") + #expect(cell.updateColumn(for: message), "same row again, on screen — in place") + #expect(!cell.updateColumn(for: ChatMessage(id: "b", text: "other row", sender: .me)), + "different row — not in place") + } + + @Test("A recycled cell configured for a different row is not an in-place update") + func inPlaceGate_freshOrOffWindow() { + let cell = ChatMessageCell(frame: CGRect(x: 0, y: 0, width: 390, height: 56)) + let message = ChatMessage(id: "a", text: "hi", sender: .me) + #expect(!cell.updateColumn(for: message), "never configured — not in place") + #expect(!cell.updateColumn(for: message), "off-window — nothing visible to animate") + } + + // MARK: - Corner morph + + @Test("An animated radii change spring-morphs the bubble's mask path") + func cornerMorph_animatedRadiiChange_addsSpring() { + let background = BubbleBackgroundView(frame: CGRect(x: 0, y: 0, width: 200, height: 40)) + background.apply(fill: .white, radii: BubbleBackgroundView.radii(isFromSelf: true, groupedAbove: false, groupedBelow: false)) + background.layoutIfNeeded() + + background.apply( + fill: .white, + radii: BubbleBackgroundView.radii(isFromSelf: true, groupedAbove: false, groupedBelow: true), + animated: true + ) + #expect(background.layer.mask?.animation(forKey: "cornerMorph") != nil) + } + + @Test("A non-animated radii change does not animate, and an animated no-op change does not either") + func cornerMorph_notAnimatedWithoutChangeOrFlag() { + let background = BubbleBackgroundView(frame: CGRect(x: 0, y: 0, width: 200, height: 40)) + let standalone = BubbleBackgroundView.radii(isFromSelf: true, groupedAbove: false, groupedBelow: false) + background.apply(fill: .white, radii: standalone) + background.layoutIfNeeded() + + // Same radii, animated: nothing to morph. + background.apply(fill: .white, radii: standalone, animated: true) + #expect(background.layer.mask?.animation(forKey: "cornerMorph") == nil) + + // Changed radii, not animated (a recycled cell rendering a different row). + background.apply(fill: .white, radii: BubbleBackgroundView.radii(isFromSelf: true, groupedAbove: true, groupedBelow: false)) + #expect(background.layer.mask?.animation(forKey: "cornerMorph") == nil) + } + + @Test("A recycled cell's direct apply drops an in-flight morph so the old row's shape can't play over the new row") + func cornerMorph_recycledCellDropsInFlightMorph() { + let background = BubbleBackgroundView(frame: CGRect(x: 0, y: 0, width: 200, height: 40)) + background.apply(fill: .white, radii: BubbleBackgroundView.radii(isFromSelf: true, groupedAbove: false, groupedBelow: false)) + background.layoutIfNeeded() + background.apply( + fill: .white, + radii: BubbleBackgroundView.radii(isFromSelf: true, groupedAbove: false, groupedBelow: true), + animated: true + ) + #expect(background.layer.mask?.animation(forKey: "cornerMorph") != nil) + + // Recycled for a different row while the morph is still running: the direct path snaps. + background.apply(fill: .white, radii: BubbleBackgroundView.radii(isFromSelf: false, groupedAbove: false, groupedBelow: false)) + #expect(background.layer.mask?.animation(forKey: "cornerMorph") == nil) + } +} diff --git a/FlipcashTests/Chat/ChatViewControllerTests.swift b/FlipcashTests/Chat/ChatViewControllerTests.swift index 7956786c..1c8827e3 100644 --- a/FlipcashTests/Chat/ChatViewControllerTests.swift +++ b/FlipcashTests/Chat/ChatViewControllerTests.swift @@ -15,7 +15,7 @@ import FlipcashCore struct ChatViewControllerTests { private func item(_ i: Int, _ sender: ChatMessage.Sender = .me) -> ChatItem { - .message(ChatMessage(id: "msg-\(i)", text: "message \(i)", sender: sender)) + .text("msg-\(i)", sender: sender) } @Test("Update renders one item per message") @@ -70,23 +70,18 @@ struct ChatViewControllerTests { // A new own message updates the previous row (receipt migrates off it, isContinuedByNext // flips) in the same push that inserts the new row. On a live window this must apply as a // single batch update — reconfigure + insert together — without throwing or dropping rows. - let controller = ChatViewController() - let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) - window.rootViewController = controller - window.makeKeyAndVisible() + let (controller, window) = ChatViewController.windowed() + defer { _ = window } let before = (0..<8).map { item($0, $0.isMultiple(of: 2) ? .me : .other) } + [ - .message(ChatMessage(id: "sent-1", text: "first send", sender: .me, receipt: "Delivered")), + .text("sent-1", receipt: "Delivered"), ] controller.update(items: before) - for _ in 0..<3 { - controller.view.layoutIfNeeded() - try? await Task.sleep(for: .milliseconds(40)) - } + await controller.settle() let after = (0..<8).map { item($0, $0.isMultiple(of: 2) ? .me : .other) } + [ - .message(ChatMessage(id: "sent-1", text: "first send", sender: .me, isContinuedByNext: true)), - .message(ChatMessage(id: "sent-2", text: "second send", sender: .me, isContinuationFromPrevious: true, receipt: "Delivered")), + .text("sent-1", continuedByNext: true), + .text("sent-2", continuationFromPrevious: true, receipt: "Delivered"), ] controller.update(items: after) #expect(controller.collectionView.numberOfItems(inSection: 0) == after.count) @@ -96,45 +91,76 @@ struct ChatViewControllerTests { func update_arrivalWhileTyping_appliesInOneBatch() async { // The riskiest merged shape: the typing indicator deletes, the reply inserts at the same // position, and the previous row reconfigures — all in one batch on a live window. - let controller = ChatViewController() - let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) - window.rootViewController = controller - window.makeKeyAndVisible() + let (controller, window) = ChatViewController.windowed() + defer { _ = window } let before: [ChatItem] = (0..<8).map { item($0, $0.isMultiple(of: 2) ? .me : .other) } + [ - .message(ChatMessage(id: "them-1", text: "typing next", sender: .other)), + .text("them-1", sender: .other), .typingIndicator, ] controller.update(items: before) - for _ in 0..<3 { - controller.view.layoutIfNeeded() - try? await Task.sleep(for: .milliseconds(40)) - } + await controller.settle() let after: [ChatItem] = (0..<8).map { item($0, $0.isMultiple(of: 2) ? .me : .other) } + [ - .message(ChatMessage(id: "them-1", text: "typing next", sender: .other, isContinuedByNext: true)), - .message(ChatMessage(id: "them-2", text: "the reply", sender: .other, isContinuationFromPrevious: true)), + .text("them-1", sender: .other, continuedByNext: true), + .text("them-2", sender: .other, continuationFromPrevious: true), ] controller.update(items: after) #expect(controller.collectionView.numberOfItems(inSection: 0) == after.count) } + // MARK: - Animation fence (regression: the missing-final-attributes crash) + + // ChatLayout's `restoreContentOffset` deliberately answers attribute queries with nil while + // it re-anchors. An inset write or transcript push overlapping a settling batch spring is + // how that nil met UIKit's animated-bounds-change cross-fade and threw + // "missing final attributes for cell" (the repro: Send with a hardware keyboard — the + // composer collapse's inset change replayed mid-animation). These pin the fence behavior: + // work arriving mid-transaction defers, then replays once the springs settle. + + @Test("An inset arriving mid-animated-update is deferred, then applied once it settles") + func setBottomInset_duringAnimatedUpdate_deferredThenApplied() async { + let (controller, window) = ChatViewController.windowed() + defer { _ = window } + let base = (0..<12).map { item($0, $0.isMultiple(of: 2) ? .me : .other) } + controller.update(items: base) + await controller.settle(turns: 6) + + controller.update(items: base + [item(99)]) // animated batch — the fence is up + controller.setBottomInset(100) + // 112 = 100 + the transcript's 12pt bottom content padding. + #expect(controller.collectionView.contentInset.bottom != 112, + "the write must not land while the batch's spring settles") + await controller.settle(until: { controller.collectionView.contentInset.bottom == 112 }) + #expect(controller.collectionView.contentInset.bottom == 112) + } + + @Test("A push arriving mid-animated-update is deferred and the latest wins") + func update_duringAnimatedUpdate_deferredLatestWins() async { + let (controller, window) = ChatViewController.windowed() + defer { _ = window } + let base = (0..<10).map { item($0) } + controller.update(items: base) + await controller.settle(turns: 6) + + controller.update(items: base + [item(50)]) // batch A — fence up + controller.update(items: base + [item(50), item(51)]) // held + controller.update(items: base + [item(50), item(51), item(52)]) // held — latest wins + #expect(controller.collectionView.numberOfItems(inSection: 0) == 11, + "only batch A applies while its spring settles") + await controller.settle(until: { controller.collectionView.numberOfItems(inSection: 0) == 13 }) + #expect(controller.collectionView.numberOfItems(inSection: 0) == 13) + } + @Test("Opens at the bottom (newest message) on first layout") func opensAtBottom() async { - let controller = ChatViewController() - let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) - window.rootViewController = controller - window.makeKeyAndVisible() + let (controller, window) = ChatViewController.windowed() + defer { _ = window } controller.update(items: (0..<40).map { item($0, $0.isMultiple(of: 2) ? .me : .other) }) - - // Let layout and self-sizing settle across a few main-runloop turns. - for _ in 0..<6 { - controller.view.layoutIfNeeded() - try? await Task.sleep(for: .milliseconds(40)) - } + await controller.settle(turns: 6) let collectionView = controller.collectionView! let maxOffset = max(0, collectionView.contentSize.height diff --git a/FlipcashTests/TestSupport/ChatItem+TestSupport.swift b/FlipcashTests/TestSupport/ChatItem+TestSupport.swift new file mode 100644 index 00000000..9448cc37 --- /dev/null +++ b/FlipcashTests/TestSupport/ChatItem+TestSupport.swift @@ -0,0 +1,32 @@ +// +// ChatItem+TestSupport.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import FlipcashCore +@testable import FlipcashUI + +extension ChatItem { + + /// A text-message row for transcript fixtures — the one `ChatMessage` factory shared by the + /// chat suites, so an init change lands in a single place. Grouping and receipt knobs + /// default off; the text derives from the id. + static func text( + _ id: String, + sender: ChatMessage.Sender = .me, + continuationFromPrevious: Bool = false, + continuedByNext: Bool = false, + receipt: String? = nil + ) -> ChatItem { + .message(ChatMessage( + id: id, + text: "text-\(id)", + sender: sender, + isContinuationFromPrevious: continuationFromPrevious, + isContinuedByNext: continuedByNext, + receipt: receipt + )) + } +} diff --git a/FlipcashTests/TestSupport/ChatViewController+TestSupport.swift b/FlipcashTests/TestSupport/ChatViewController+TestSupport.swift new file mode 100644 index 00000000..7cc1a176 --- /dev/null +++ b/FlipcashTests/TestSupport/ChatViewController+TestSupport.swift @@ -0,0 +1,54 @@ +// +// ChatViewController+TestSupport.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit +import FlipcashCore +@testable import FlipcashUI + +extension ChatViewController { + + /// A controller with its view loaded and `items` applied — the arrange step shared by the + /// chat suites. Off-window, both animated and non-animated updates render synchronously. + static func loaded(items: [ChatItem], animated: Bool = true) -> ChatViewController { + let controller = ChatViewController() + controller.loadViewIfNeeded() + controller.update(items: items, animated: animated) + return controller + } + + /// Hosts a fresh controller in a key window sized like an iPhone, so updates run the + /// on-screen (animated batch) path. The window is returned because the caller must keep it + /// alive for the test's duration — dropping it detaches the view mid-test. + static func windowed() -> (controller: ChatViewController, window: UIWindow) { + let controller = ChatViewController() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = controller + window.makeKeyAndVisible() + return (controller, window) + } + + /// Lets layout, self-sizing, and in-flight batch updates settle across main-runloop turns. + /// UIKit exposes no awaitable hook for collection-view batch-update completion, so this + /// drains the run loop in short turns — tune the timing here, never per test. + func settle(turns: Int = 3) async { + for _ in 0.. Bool, timeout: TimeInterval = 5) async { + let start = ContinuousClock.now + while !condition(), ContinuousClock.now - start < .seconds(timeout) { + view.layoutIfNeeded() + try? await Task.sleep(for: .milliseconds(40)) + } + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift index ca1af38e..d822eaff 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift @@ -8,6 +8,7 @@ #if canImport(UIKit) import UIKit import SwiftUI +import FlipcashCore /// The shared chrome behind every chat bubble and cash card: a white-opacity fill with a hairline /// border and a continuous, per-corner rounded shape. A same-sender run flattens the inner corners @@ -36,21 +37,66 @@ final class BubbleBackgroundView: UIView { @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func apply(fill: UIColor, radii: RectangleCornerRadii) { + /// Applies the full chrome for a message — sender fill plus grouping radii. The single + /// mapping from message to chrome, shared by text bubbles and cash cards. + func apply(for message: ChatMessage, animated: Bool = false) { + apply( + fill: Self.fill(isFromSelf: message.sender == .me), + radii: Self.radii( + isFromSelf: message.sender == .me, + groupedAbove: message.isContinuationFromPrevious, + groupedBelow: message.isContinuedByNext + ), + animated: animated + ) + } + + /// Applies the chrome. Pass `animated: true` on an in-place reconfigure whose grouping changed + /// (a new same-sender message flattening this bubble's corner) so the shape morphs on the + /// prototype's corner spring instead of snapping. Both paths come from the same + /// `UnevenRoundedRectangle` topology, so the spring interpolates them cleanly. + func apply(fill: UIColor, radii: RectangleCornerRadii, animated: Bool = false) { backgroundColor = fill + let previous = self.radii self.radii = radii - setNeedsLayout() + // A recycled cell applies directly (not animated) and can land mid-morph; drop the + // in-flight animation so the new row renders its own shape instead of the old row's morph + // playing over it. An in-place reconfigure keeps the same row's running morph. + if !animated { + shapeMask.removeAnimation(forKey: "cornerMorph") + borderLayer.removeAnimation(forKey: "cornerMorph") + } + guard animated, !ChatMotion.isReduced, radii != previous, !bounds.isEmpty else { + setNeedsLayout() + return + } + let path = shapePath + for layer in [shapeMask, borderLayer] { + let morph = CASpringAnimation(perceptualDuration: ChatMotion.cornerMorph.duration, bounce: ChatMotion.cornerMorph.bounce) + morph.keyPath = "path" + // Retarget from wherever a still-running morph currently is, not its final value. + morph.fromValue = layer.presentation()?.path ?? layer.path + morph.toValue = path + layer.add(morph, forKey: "cornerMorph") + layer.path = path + } } /// The bubble's continuous, per-corner rounded shape in its own coordinate space — the same /// geometry used for the layer mask. Clips the context-menu lift preview to the bubble. var maskingPath: UIBezierPath { - UIBezierPath(cgPath: UnevenRoundedRectangle(cornerRadii: radii, style: .continuous).path(in: bounds).cgPath) + UIBezierPath(cgPath: shapePath) + } + + /// The current radii rendered into this view's bounds — the one geometry every consumer + /// (mask, border, morph target, lift-preview clip) draws. + private var shapePath: CGPath { + UnevenRoundedRectangle(cornerRadii: radii, style: .continuous).path(in: bounds).cgPath } override func layoutSubviews() { super.layoutSubviews() - let path = UnevenRoundedRectangle(cornerRadii: radii, style: .continuous).path(in: bounds).cgPath + let path = shapePath shapeMask.path = path borderLayer.path = path borderLayer.frame = bounds diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatAnimationFence.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatAnimationFence.swift new file mode 100644 index 00000000..40fe0164 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatAnimationFence.swift @@ -0,0 +1,64 @@ +// +// ChatAnimationFence.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import Foundation + +/// Serializes the transcript's animated layout work, the way the ChatLayout example's +/// `SetActor`/interface-actions machinery does upstream. +/// +/// `CollectionViewChatLayout.restoreContentOffset(with:)` deliberately answers every attribute +/// query with nil while it re-anchors, and it forces a layout pass in that state. If that pass +/// collides with animated layout work the collection view already has in flight — a settling +/// batch-update spring, or an inset write that UIKit classified as an animated bounds change — +/// UIKit throws `NSInternalInconsistencyException: missing final attributes for cell`. +/// +/// The fence makes the collision impossible by construction: every animated transaction brackets +/// itself with `begin()`/`end()`, and work that must not overlap one (transcript pushes, inset +/// writes, offset restores) goes through `whenIdle` — running immediately when nothing is in +/// flight, and replaying in order once the last transaction settles. A replayed closure that +/// starts a new transaction (a held transcript push beginning its own batch) re-defers whatever +/// is still queued behind it, so replays can never overlap each other either. +@MainActor +final class ChatAnimationFence { + + private(set) var activeCount = 0 + private var idleWork: [() -> Void] = [] + private var isDraining = false + + var isActive: Bool { activeCount > 0 } + + /// An animated transaction (a batch-update spring, an animated scroll) has started. + func begin() { + activeCount += 1 + } + + /// The transaction settled. When it was the last one, queued work replays in order. + func end() { + assert(activeCount > 0, "Unbalanced ChatAnimationFence.end()") + activeCount = max(0, activeCount - 1) + drainIfIdle() + } + + /// Run `work` now if nothing is animating, otherwise once the last transaction settles. + func whenIdle(_ work: @escaping () -> Void) { + idleWork.append(work) + drainIfIdle() + } + + private func drainIfIdle() { + guard !isDraining else { return } + isDraining = true + defer { isDraining = false } + // Re-check before every item: a replayed closure may begin a new transaction, and the + // remainder must wait behind it — never run inside it. + while activeCount == 0, !idleWork.isEmpty { + idleWork.removeFirst()() + } + } +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift index 29877a9b..5855e07a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift @@ -53,19 +53,14 @@ public final class ChatBubbleView: UIView { /// The background is pinned to every edge, so its bounds match the bubble's. var maskingPath: UIBezierPath { background.maskingPath } - public func configure(with message: ChatMessage) { + /// - Parameter animatingCorners: morph a grouping-driven corner change instead of snapping it. + /// Pass true only for an on-screen, in-place reconfigure of the same row. + public func configure(with message: ChatMessage, animatingCorners: Bool = false) { switch message.content { case .text(let text): label.text = text case .cash: label.text = nil // cash rows use a dedicated cell, not this bubble } - background.apply( - fill: BubbleBackgroundView.fill(isFromSelf: message.sender == .me), - radii: BubbleBackgroundView.radii( - isFromSelf: message.sender == .me, - groupedAbove: message.isContinuationFromPrevious, - groupedBelow: message.isContinuedByNext - ) - ) + background.apply(for: message, animated: animatingCorners) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift index 338dc39d..4ab2d375 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift @@ -117,7 +117,14 @@ public final class ChatCashCardCell: ChatColumnCell { } public func configure(with message: ChatMessage) { - guard case .cash(let cash) = message.content else { return } + let cash: ChatCashContent + switch message.content { + case .cash(let content): + cash = content + case .text: + return // text rows use ChatMessageCell, not this card + } + let inPlace = updateColumn(for: message) tokenLabel.text = cash.token captionLabel.text = message.sender == .me ? "You sent" : "You received" amountLabel.text = cash.amount @@ -129,15 +136,7 @@ public final class ChatCashCardCell: ChatColumnCell { coinIcon.isHidden = cash.iconURL == nil coinIcon.kf.setImage(with: cash.iconURL) - card.apply( - fill: BubbleBackgroundView.fill(isFromSelf: message.sender == .me), - radii: BubbleBackgroundView.radii( - isFromSelf: message.sender == .me, - groupedAbove: message.isContinuationFromPrevious, - groupedBelow: message.isContinuedByNext - ) - ) - updateColumn(for: message) + card.apply(for: message, animated: inPlace) // Resting alpha lives here, not just in prepareForReuse: an in-place reconfigure (a new // message flipping this row's grouping) re-runs configure without prepareForReuse, so this is diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift index cabb8ec1..ed999f1b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift @@ -27,9 +27,10 @@ public class ChatColumnCell: UICollectionViewCell { /// Tap-to-retry recognizer, enabled only while this row is failed so non-failed bubbles don't /// consume taps (and a future single-tap affordance isn't pre-empted). private var retryTap: UITapGestureRecognizer? - /// The id of the message this cell currently renders. The receipt is cross-faded only when the - /// *same* row changes in place; a recycled cell reconfigured for a different id sets its line - /// directly, so it never replays this cell's prior line (a reused failed cell flashing red). + /// The id of the message this cell currently renders — the input to the in-place gate + /// (`isInPlaceUpdate`): change animations (the receipt reveal/swap, the corner morph) play + /// only when the *same* row updates on screen, so a recycled cell never replays motion that + /// belongs to another message (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 @@ -65,29 +66,41 @@ public class ChatColumnCell: UICollectionViewCell { currentMessageID = nil retryID = nil retryTap?.isEnabled = false - // Clear the line so a recycled cell never carries its prior row's text/color into the next use. + // Clear the line so a recycled cell never carries its prior row's text/color into the next + // use, and drop a mid-flight reveal so its fade can't play over the next row's line (the + // model values already rest at alpha 1 / identity — only the presentation layer is behind). + receipt.layer.removeAllAnimations() receipt.text = nil receipt.isHidden = true receipt.textColor = ChatReceiptLabel.defaultColor } - /// Sets the status line and hugs the column to the sender's edge. Call from `configure`. The text - /// is supplied by the mapping (`message.receipt`); this only styles it — a failed row turns red and - /// becomes tappable to retry. - func updateColumn(for message: ChatMessage) { - // Cross-fade the receipt only when the *same* row changes in place (Delivered→Read, the settling - // line revealing). A recycled or freshly dequeued cell renders a different row, so its line is set - // directly — otherwise the cross-fade would replay this cell's prior line (a reused failed cell - // flashing red "Not Delivered" before resolving to the real line). - let isInPlaceUpdate = currentMessageID == message.id + /// Sets the status line and hugs the column to the sender's edge. Call *first* from `configure`. + /// The text is supplied by the mapping (`message.receipt`); this only styles it — a failed row + /// turns red and becomes tappable to retry. + /// + /// Returns whether `message` re-rendered the row this cell already showed on screen — the gate + /// for the subclass's own change animations (the corner morph), computed before the cell + /// adopts the new id. + func updateColumn(for message: ChatMessage) -> Bool { + let inPlace = isInPlaceUpdate(for: message) currentMessageID = message.id // A failed row is the only interactive/red one — every signal keys off that single condition. retryID = message.isFailed ? message.id : nil receipt.textColor = message.isFailed ? ChatReceiptLabel.failedColor : ChatReceiptLabel.defaultColor retryTap?.isEnabled = message.isFailed - setReceipt(message.receipt, animated: isInPlaceUpdate && window != nil) + setReceipt(message.receipt, animated: inPlace) column.alignment = message.sender == .me ? .trailing : .leading applyAlignment(isFromSelf: message.sender == .me) + return inPlace + } + + /// Whether `message` re-renders the row this cell already shows, on screen — the gate for + /// view-level change animations (receipt reveal, corner morph). A recycled or freshly dequeued + /// cell renders a *different* row, so its changes apply directly instead of replaying an + /// animation that belongs to another message. + private func isInPlaceUpdate(for message: ChatMessage) -> Bool { + currentMessageID == message.id && window != nil } @objc private func retryTapped() { @@ -97,14 +110,31 @@ public class ChatColumnCell: UICollectionViewCell { private func setReceipt(_ text: String?, animated: Bool) { guard receipt.text != text else { return } - // Cross-fade the line in (nil→text) and across the Delivered→Read swap; let it snap away when - // it clears so the row collapses in step with the batch update rather than after the fade. The - // visibility change is applied synchronously, outside the transition, so the cell's self-sized - // height never lags the cross-fade. + // A revealing line scale/fades in (the prototype's "Delivered" pop) and a text swap + // cross-fades in place (Delivered→Read); the line snaps away when it clears so the row + // collapses in step with the batch update rather than after the fade. Text and visibility + // are applied synchronously, outside the animation, so the cell's self-sized height never + // lags the motion. if animated, text != nil { - receipt.isHidden = false - UIView.transition(with: receipt, duration: 0.25, options: .transitionCrossDissolve) { - self.receipt.text = text + if receipt.isHidden { + // The reveal usually runs inside the batch update's own animation block (a + // reconfigure re-runs configure there), so the starting state must be fenced off + // from that ambient spring — otherwise alpha/transform are captured as animations + // from their *current* values and the pop is lost. + UIView.performWithoutAnimation { + receipt.text = text + receipt.isHidden = false + receipt.alpha = 0 + receipt.transform = CGAffineTransform(scaleX: ChatMotion.receiptRevealScale, y: ChatMotion.receiptRevealScale) + } + UIView.animate(springDuration: ChatMotion.receiptReveal.duration, bounce: ChatMotion.receiptReveal.bounce, options: [.overrideInheritedDuration]) { + self.receipt.alpha = 1 + self.receipt.transform = .identity + } + } else { + UIView.transition(with: receipt, duration: ChatMotion.receiptSwapDuration, options: .transitionCrossDissolve) { + self.receipt.text = text + } } } else { receipt.text = text diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift index 998ff83a..f12e598b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift @@ -38,9 +38,9 @@ public final class ChatMessageCell: ChatColumnCell { /// - Parameter maxWidth: the widest the bubble may grow before its text wraps, in points. /// The owner derives it from the collection view's width. public func configure(with message: ChatMessage, maxWidth: CGFloat) { - bubble.configure(with: message) + let inPlace = updateColumn(for: message) + bubble.configure(with: message, animatingCorners: inPlace) maxWidthConstraint.constant = maxWidth - updateColumn(for: message) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift new file mode 100644 index 00000000..68d7a3e5 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift @@ -0,0 +1,42 @@ +// +// ChatMotion.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import SwiftUI +import UIKit + +/// The transcript's motion language — spring parameters ported from the design prototype, where +/// they were tuned as SwiftUI `.spring(duration:bounce:)` values. `Spring` is SwiftUI's own type; +/// `UIView.animate(springDuration:bounce:)` and `CASpringAnimation(perceptualDuration:bounce:)` +/// take the same pair directly, so the numbers carry over verbatim. +/// +/// Reduce Motion is resolved here, at the token: bounces collapse to 0 and entrance scales to 1, +/// so consuming sites degrade to plain fades and settles with no branching of their own. Block-based +/// UIView/CA animations aren't gated by the system. A site that must *skip* an animation entirely +/// under Reduce Motion (the corner morph, which would otherwise still slowly change shape) checks +/// `isReduced` explicitly. +enum ChatMotion { + + static var isReduced: Bool { UIAccessibility.isReduceMotionEnabled } + + /// A new bubble (or the typing indicator) entering, and the batch transaction it rides in. + static var insertion: Spring { Spring(duration: 0.23, bounce: isReduced ? 0 : 0.27) } + /// Scale an entering row grows in from, anchored at its sender's edge. + static var insertionScale: CGFloat { isReduced ? 1 : 0.95 } + /// The animated scroll that brings the newest message into view. + static var scroll: Spring { Spring(duration: 0.30, bounce: isReduced ? 0 : 0.12) } + /// A receipt line ("Delivered") revealing under a bubble. + static var receiptReveal: Spring { Spring(duration: 0.40, bounce: isReduced ? 0 : 0.12) } + /// Scale the receipt line grows in from. + static var receiptRevealScale: CGFloat { isReduced ? 1 : 0.95 } + /// The Delivered → Read label swap. + static let receiptSwapDuration: TimeInterval = 0.26 + /// A bubble's corner radius morph when its grouping changes. Skipped, not softened, under + /// Reduce Motion — see `BubbleBackgroundView.apply`. + static let cornerMorph = Spring(duration: 0.45, bounce: 0.32) +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 7a474602..575cfb51 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -54,8 +54,19 @@ public final class ChatViewController: UICollectionViewController { /// Breathing room kept below the last item, above the bar, so a trailing receipt doesn't sit /// flush against the bar. private static let bottomContentPadding: CGFloat = 12 - /// True while a batch update animates, so the top trigger doesn't re-fire mid-update. - private var isUpdating = false + /// Serializes all animated layout work: the batch-update spring and the animated scroll + /// bracket themselves as transactions, and transcript pushes, inset writes, and offset + /// restores that arrive mid-transaction are held and replayed once everything settles. See + /// `ChatAnimationFence` for the crash this prevents. Transactions count until their *spring + /// settles*, not until the batch commits — a boolean cleared in the batch completion left a + /// window where the spring was still animating but the gates were down. + private let fence = ChatAnimationFence() + /// A bar inset that arrived mid-transaction, applied when the fence drains. Latest wins. + private var pendingBottomInset: CGFloat? + /// Ids of the rows the in-flight update inserted, each owed an entrance animation when its + /// cell surfaces (`playEntranceIfNeeded`). Cleared when the update's transaction settles, so + /// a row displayed later — scrolling back up to a prepended page — doesn't replay one. + private var pendingEntranceIDs: Set = [] /// Set while `setBottomInset` writes the content inset — that write synchronously fires /// `scrollViewDidChangeAdjustedContentInset`, and this stops the delegate re-entering `scrollToBottom` /// → `restoreContentOffset` mid-write, a nested layout pass that crashes ChatLayout. @@ -70,9 +81,11 @@ public final class ChatViewController: UICollectionViewController { private var savedInsetBehavior: UIScrollView.ContentInsetAdjustmentBehavior? private var savedContentInset: UIEdgeInsets? private var savedScrollIndicatorInsets: UIEdgeInsets? - /// 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 transcript pushed while it couldn't be applied — a context menu was up (reloading would + /// reflow the content out from under the lifted preview) or an animated transaction was still + /// settling. The latest push replays when the blocker clears. Mirrors the ChatLayout example + /// deferring `processUpdates` while any interface action is active. + private var deferredItems: (items: [ChatItem], animated: Bool)? public init() { super.init(collectionViewLayout: chatLayout) @@ -127,8 +140,14 @@ public final class ChatViewController: UICollectionViewController { // While a context menu is up the inset is frozen (`freezeInset`), so this shouldn't fire for the // keyboard — but guard anyway, since taking the inset over and handing it back each toggles the // adjusted inset, and following those would move the content the freeze is holding in place. - guard !isAdjustingBottomInset, !isShowingContextMenu, wasAtBottom, !needsInitialScroll, !isUpdating, !items.isEmpty else { return } - scrollToBottom(animated: false) + guard !isAdjustingBottomInset, !isShowingContextMenu, wasAtBottom, !needsInitialScroll, !items.isEmpty else { return } + // Mid-transaction the follow is deferred, not dropped — dropping it leaves the newest + // message behind the keyboard. Conditions re-checked at replay: the user may have + // scrolled away or a menu may have opened while the springs settled. + fence.whenIdle { [weak self] in + guard let self, wasAtBottom, !isShowingContextMenu, !needsInitialScroll, !items.isEmpty else { return } + scrollToBottom(animated: false) + } } // MARK: - Updates @@ -142,7 +161,15 @@ public final class ChatViewController: UICollectionViewController { // arriving message) would reflow the content out from under the lifted preview. The latest // push is applied when the menu closes. Mirrors ChatLayout deferring updates during a preview. guard !isShowingContextMenu else { - deferredItems = newItems + deferredItems = (newItems, animated) + return + } + // Serialize behind in-flight animations. Overlapping animated batches was the crash: a + // superseded completion dropped the update gate while a newer batch's spring still + // animated, letting an inset write and its offset restore land mid-animation. + guard !fence.isActive else { + deferredItems = (newItems, animated) + fence.whenIdle { [weak self] in self?.flushDeferredItems() } return } // The owner re-pushes on every observable change (read receipts, the live stream, paging @@ -173,12 +200,19 @@ public final class ChatViewController: UICollectionViewController { items = newItems return } - isUpdating = true + // Rows this push inserts, by id — each owed a cell entrance when it surfaces. + pendingEntranceIDs = Set(newItems.map(\.id)).subtracting(items.map(\.id)) + fence.begin() collectionView.reload( using: changeset, - // A change too large to animate falls back to a reload that keeps the bottom-anchored - // position rather than animating hundreds of rows. - interrupt: { $0.changeCount > 100 }, + // The whole transaction — cell shifts, keep-at-bottom compensation, entrance + // transforms — moves on the prototype's insertion spring. + animatingWith: ChatMotion.insertion, + // A structural change too large to animate falls back to a reload that keeps the + // bottom-anchored position rather than animating hundreds of rows. Reconfigures don't + // count: they restyle rows in place, and the flattened changeset would otherwise sum + // them into the total and demote an animatable page-plus-updates push to a hard reload. + interrupt: { $0.elementDeleted.count + $0.elementInserted.count + $0.elementMoved.count > 100 }, onInterruptedReload: { [weak self] in guard let self else { return } let snapshot = chatLayout.getContentOffsetSnapshot(from: .bottom) @@ -188,7 +222,9 @@ public final class ChatViewController: UICollectionViewController { } }, completion: { [weak self] _ in - self?.isUpdating = false + guard let self else { return } + pendingEntranceIDs = [] + fence.end() }, setData: { [weak self] data in self?.items = data @@ -196,6 +232,14 @@ public final class ChatViewController: UICollectionViewController { ) } + /// Apply the newest transcript that was pushed while a context menu or an animated + /// transaction had updates on hold. Re-enters `update(items:)`, which re-checks every gate. + private func flushDeferredItems() { + guard !isShowingContextMenu, let pending = deferredItems else { return } + deferredItems = nil + update(items: pending.items, animated: pending.animated) + } + // MARK: - Data source public override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { @@ -269,7 +313,29 @@ public final class ChatViewController: UICollectionViewController { /// cell loses its `CAAnimation`s, and `willDisplay`/`didEndDisplaying` are the reliable per-appearance /// hooks, so the wave restarts every time the row is (re)inserted. public override func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + // A recycled cell may carry a half-finished entrance; every display starts clean. + cell.transform = .identity (cell as? ChatTypingIndicatorCell)?.startAnimating() + playEntranceIfNeeded(on: cell, at: indexPath) + } + + /// The prototype's entrance — scale from 0.95 anchored at the sender's edge, riding the same + /// spring as the batch it enters in — applied to the *cell*, never to layout attributes. + /// ChatLayout round-trips attribute frames through its keep-at-bottom compensation, so a + /// scale baked into an attribute frame corrupts the layout's stored frames (mis-sized rows, + /// unsatisfiable-constraint spam, and grist for the missing-final-attributes crash); + /// attributes stay at the library's default pure fade. A cell transform is invisible to the + /// layout and composes with the batch's own fade and settle. + private func playEntranceIfNeeded(on cell: UICollectionViewCell, at indexPath: IndexPath) { + guard !pendingEntranceIDs.isEmpty, items.indices.contains(indexPath.item) else { return } + let item = items[indexPath.item] + guard pendingEntranceIDs.remove(item.id) != nil else { return } + let transform = Self.entranceTransform(for: item, width: cell.bounds.width) + guard transform != .identity else { return } + cell.transform = transform + UIView.animate(springDuration: ChatMotion.insertion.duration, bounce: ChatMotion.insertion.bounce, options: [.allowUserInteraction]) { + cell.transform = .identity + } } public override func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { @@ -296,11 +362,19 @@ public final class ChatViewController: UICollectionViewController { edge: .bottom ) 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) + // Restores force a layout pass ChatLayout answers with nil attributes — never run one + // mid-transaction (see `ChatAnimationFence`). + fence.whenIdle { [weak self] in + guard let self else { return } + 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 + guard let self else { return } + fence.whenIdle { [weak self] in + self?.chatLayout.restoreContentOffset(with: snapshot) + } + } } return } @@ -308,11 +382,17 @@ public final class ChatViewController: UICollectionViewController { - collectionView.bounds.height + collectionView.adjustedContentInset.bottom guard target > collectionView.contentOffset.y else { return } - UIView.animate(withDuration: 0.25, animations: { + fence.begin() + UIView.animate(springDuration: ChatMotion.scroll.duration, bounce: ChatMotion.scroll.bounce, animations: { 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). - self.chatLayout.restoreContentOffset(with: snapshot) + // End first — draining may replay a held push or inset — then lock to the exact + // bottom edge once whatever the replay started has settled too (the estimate may + // have moved while the spring ran). + self.fence.end() + self.fence.whenIdle { [weak self] in + self?.chatLayout.restoreContentOffset(with: snapshot) + } }) } @@ -326,7 +406,7 @@ public final class ChatViewController: UICollectionViewController { // Don't paginate while a batch update animates, or before the opening scroll-to-bottom has // run — on open the content sits at the top for a beat, which would otherwise fire a stray // older-page load. - guard !isUpdating, !needsInitialScroll else { return } + guard !fence.isActive, !needsInitialScroll else { return } // ChatLayout's canonical reverse-pagination trigger: while within one screen of the top. // The owner's loader is guarded, so firing repeatedly is fine, and it inherently only // fires when scrolled up — which is exactly "paginate only while scrolled up". @@ -341,20 +421,44 @@ public final class ChatViewController: UICollectionViewController { /// scrolled-up stays put — no hand-computed offset. public func setBottomInset(_ inset: CGFloat) { // The inset is frozen while a context menu is up; don't touch it (it's restored on close). - guard !isShowingContextMenu else { 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. + guard isViewLoaded, !isShowingContextMenu else { return } + // Never change the inset mid-transaction: the write arms an animated bounds change UIKit + // wants to cross-fade, and the re-anchor one line later forces a layout pass ChatLayout + // answers with nil attributes — UIKit throws "missing final attributes for cell". Hold + // the latest value and replay it once the fence drains; dropping it instead (the old + // behavior) relied on a later layout pass that could itself land mid-animation. + guard !fence.isActive else { + if pendingBottomInset == nil { + fence.whenIdle { [weak self] in + guard let self, let pending = pendingBottomInset else { return } + pendingBottomInset = nil + setBottomInset(pending) + } + } + pendingBottomInset = inset + return + } let target = inset + Self.bottomContentPadding - guard isViewLoaded, !isUpdating, abs(collectionView.contentInset.bottom - target) > 0.5 else { return } + guard abs(collectionView.contentInset.bottom - target) > 0.5 else { return } + + // The ChatLayout example's keyboard recipe (`keyboardWillChangeFrame`): complete any + // leftover batch state, write the inset inside `performBatchUpdates` — the one + // bounds-change route the layout fully coordinates — and re-anchor in the same + // transaction. All of it non-animated, so a caller inside an animation block (the + // keyboard's, a transition's) can't turn the write into an animated bounds change. let snapshot = chatLayout.getContentOffsetSnapshot(from: .bottom) isAdjustingBottomInset = true // suppress the delegate re-entry from the inset write below - collectionView.contentInset.bottom = target - collectionView.verticalScrollIndicatorInsets.bottom = target - isAdjustingBottomInset = false - if let snapshot { - chatLayout.restoreContentOffset(with: snapshot) + UIView.performWithoutAnimation { + collectionView.performBatchUpdates({}) + collectionView.performBatchUpdates({ + collectionView.contentInset.bottom = target + collectionView.verticalScrollIndicatorInsets.bottom = target + }) + if let snapshot { + chatLayout.restoreContentOffset(with: snapshot) + } } + isAdjustingBottomInset = false } /// Take over the inset at its current (keyboard-up) value so the keyboard leaving under the menu @@ -369,11 +473,16 @@ public final class ChatViewController: UICollectionViewController { savedInsetBehavior = collectionView.contentInsetAdjustmentBehavior savedContentInset = collectionView.contentInset savedScrollIndicatorInsets = collectionView.verticalScrollIndicatorInsets - collectionView.contentInsetAdjustmentBehavior = .never - collectionView.contentInset = frozen - collectionView.verticalScrollIndicatorInsets = frozen - if let snapshot { - chatLayout.restoreContentOffset(with: snapshot) + // The menu's own transition animates around this — force the take-over out of that + // context so the inset writes can't become an animated bounds change (the same hazard + // `setBottomInset` fences off; see `ChatAnimationFence`). + UIView.performWithoutAnimation { + collectionView.contentInsetAdjustmentBehavior = .never + collectionView.contentInset = frozen + collectionView.verticalScrollIndicatorInsets = frozen + if let snapshot { + chatLayout.restoreContentOffset(with: snapshot) + } } } @@ -383,11 +492,15 @@ public final class ChatViewController: UICollectionViewController { private func restoreInset() { guard let behavior = savedInsetBehavior else { return } let snapshot = chatLayout.getContentOffsetSnapshot(from: .bottom) - collectionView.contentInsetAdjustmentBehavior = behavior - if let inset = savedContentInset { collectionView.contentInset = inset } - if let indicator = savedScrollIndicatorInsets { collectionView.verticalScrollIndicatorInsets = indicator } - if let snapshot { - chatLayout.restoreContentOffset(with: snapshot) + // Non-animated for the same reason as `freezeInset`: the menu-dismissal transition's + // animation context must not turn the hand-back into an animated bounds change. + UIView.performWithoutAnimation { + collectionView.contentInsetAdjustmentBehavior = behavior + if let inset = savedContentInset { collectionView.contentInset = inset } + if let indicator = savedScrollIndicatorInsets { collectionView.verticalScrollIndicatorInsets = indicator } + if let snapshot { + chatLayout.restoreContentOffset(with: snapshot) + } } savedInsetBehavior = nil savedContentInset = nil @@ -395,10 +508,40 @@ public final class ChatViewController: UICollectionViewController { } } -/// The controller is the layout delegate so cells inherit ChatLayout's defaults — auto self-sizing -/// and full-width alignment. No row needs a custom size, so nothing is overridden. +/// The controller is the layout delegate. Everything keeps ChatLayout's defaults — auto +/// self-sizing, full-width alignment, and pure-fade insert/delete attributes. Entrance motion is +/// deliberately NOT applied here: the layout round-trips attribute frames through its +/// keep-at-bottom compensation, so any transform on attributes corrupts its stored frames. The +/// scale-in lives on the cell instead (`playEntranceIfNeeded`). extension ChatViewController: ChatLayoutDelegate {} +extension ChatViewController { + + /// Scale + fade anchored at the row's aligned edge — trailing for own messages, leading for + /// the counterpart's and the typing indicator. The cell spans the full width with the bubble + /// hugging that edge, so pinning the cell's edge (scale about center, then shift by half the + /// shrink) pins the bubble's. Date separators are centered and only fade — as does everything + /// under Reduce Motion, where `insertionScale` resolves to 1 and this becomes identity. + static func entranceTransform(for item: ChatItem, width: CGFloat) -> CGAffineTransform { + // -1 shifts toward the leading edge, +1 toward the trailing. + let edge: CGFloat + switch item { + case .dateSeparator: + return .identity + case .typingIndicator: + edge = -1 + case .message(let message): + switch message.sender { + case .me: edge = 1 + case .other: edge = -1 + } + } + let scale = ChatMotion.insertionScale + return CGAffineTransform(translationX: edge * width * (1 - scale) / 2, y: 0) + .scaledBy(x: scale, y: scale) + } +} + // MARK: - Context menu extension ChatViewController { @@ -407,8 +550,9 @@ extension ChatViewController { /// pasteboard — ChatLayout's canonical copy interaction, scoped to text messages. Cash cards and /// date separators carry no copyable text and opt out. public override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - // Don't offer a menu mid-batch-update: the index path may not line up with the rendered cell. - guard !isUpdating else { return nil } + // Don't offer a menu mid-transaction: the index path may not line up with the rendered + // cell, and `freezeInset`'s write + re-anchor must never overlap a settling animation. + guard !fence.isActive else { return nil } let body: String switch items[indexPath.item] { @@ -459,10 +603,7 @@ extension ChatViewController { // suppressed (no stray scroll); then drop the flag and apply any held update. restoreInset() isShowingContextMenu = false - if let pending = deferredItems { - deferredItems = nil - update(items: pending) - } + flushDeferredItems() } if let animator { animator.addCompletion(resume) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/UICollectionView+Reload.swift b/FlipcashUI/Sources/FlipcashUI/Chat/UICollectionView+Reload.swift index dcc8b796..04cb0cf5 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/UICollectionView+Reload.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/UICollectionView+Reload.swift @@ -12,6 +12,7 @@ #if canImport(UIKit) import ChatLayout import DifferenceKit +import SwiftUI import UIKit extension UICollectionView { @@ -19,21 +20,37 @@ extension UICollectionView { /// Apply a `StagedChangeset` as batch updates. Falls back to a full reload (via /// `onInterruptedReload`) when off-screen or when `interrupt` trips on a change too large to /// animate. This is what lets `keepContentOffsetAtBottomOnBatchUpdates` keep new content pinned. + /// + /// `animatingWith` times each batch transaction on the caller's spring — cell shifts, the + /// keep-at-bottom offset compensation, and any delegate-supplied entrance transforms all move + /// as one. Without it the batch rides UIKit's stock curve. func reload( using stagedChangeset: StagedChangeset, + animatingWith spring: Spring? = nil, interrupt: ((Changeset) -> Bool)? = nil, onInterruptedReload: (() -> Void)? = nil, completion: ((Bool) -> Void)? = nil, - setData: (C) -> Void + setData: @escaping (C) -> Void ) { - if case .none = window, let data = stagedChangeset.last?.data { + let fallbackReload = { (data: C) in setData(data) if let onInterruptedReload { onInterruptedReload() } else { - reloadData() + self.reloadData() } completion?(false) + } + + if window == nil, let data = stagedChangeset.last?.data { + fallbackReload(data) + return + } + + // Interrupt is decided up front, before any stage starts animating — the fallback's + // reload + re-anchor must never run while an earlier stage's animation is in flight. + if let interrupt, stagedChangeset.contains(where: interrupt), let data = stagedChangeset.last?.data { + fallbackReload(data) return } @@ -41,36 +58,39 @@ extension UICollectionView { let completionHandler: ((Bool) -> Void)? = completion != nil ? { _ in dispatchGroup!.leave() } : nil for changeset in stagedChangeset { - if let interrupt, interrupt(changeset), let data = stagedChangeset.last?.data { - setData(data) - if let onInterruptedReload { - onInterruptedReload() - } else { - reloadData() - } - completion?(false) - return - } - - performBatchUpdates({ + let updates = { setData(changeset.data) dispatchGroup?.enter() if !changeset.elementDeleted.isEmpty { - deleteItems(at: changeset.elementDeleted.map { IndexPath(item: $0.element, section: $0.section) }) + self.deleteItems(at: changeset.elementDeleted.map { IndexPath(item: $0.element, section: $0.section) }) } if !changeset.elementInserted.isEmpty { - insertItems(at: changeset.elementInserted.map { IndexPath(item: $0.element, section: $0.section) }) + self.insertItems(at: changeset.elementInserted.map { IndexPath(item: $0.element, section: $0.section) }) } if !changeset.elementUpdated.isEmpty { let indexPaths = changeset.elementUpdated.map { IndexPath(item: $0.element, section: $0.section) } - reconfigureItems(at: indexPaths) - (collectionViewLayout as? CollectionViewChatLayout)?.reconfigureItems(at: indexPaths) + self.reconfigureItems(at: indexPaths) + (self.collectionViewLayout as? CollectionViewChatLayout)?.reconfigureItems(at: indexPaths) } for (source, target) in changeset.elementMoved { - moveItem(at: IndexPath(item: source.element, section: source.section), to: IndexPath(item: target.element, section: target.section)) + self.moveItem(at: IndexPath(item: source.element, section: source.section), to: IndexPath(item: target.element, section: target.section)) } - }, completion: completionHandler) + } + + if let spring { + // The group also waits for the spring itself: springs settle past the batch's own + // completion, and `completion` is the caller's signal that no animated layout work + // remains (the fence that keeps inset writes and offset restores out of it). + dispatchGroup?.enter() + UIView.animate(springDuration: spring.duration, bounce: spring.bounce, options: [.allowUserInteraction], animations: { + self.performBatchUpdates(updates, completion: completionHandler) + }, completion: { _ in + dispatchGroup?.leave() + }) + } else { + performBatchUpdates(updates, completion: completionHandler) + } } dispatchGroup?.notify(queue: .main) { completion!(true) } } @@ -93,12 +113,12 @@ extension StagedChangeset { guard count > 1, let target = last?.data, allSatisfy({ $0.sectionChangeCount == 0 && $0.elementMoved.isEmpty }) else { return self } - return StagedChangeset(arrayLiteral: Changeset( + return [Changeset( data: target, elementDeleted: flatMap(\.elementDeleted), elementInserted: flatMap(\.elementInserted), elementUpdated: flatMap(\.elementUpdated) - )) + )] } } #endif