Skip to content

fix(chat): settle the send animation - #726

Merged
bmc08gt merged 3 commits into
mainfrom
fix/chat-send-animation
Sep 3, 2026
Merged

fix(chat): settle the send animation#726
bmc08gt merged 3 commits into
mainfrom
fix/chat-send-animation

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Four regressions in the send animation, all introduced alongside the chat edit work.

The bar's height change fought the send. A send collapses a multiline field, so setBarHeight runs at the same moment as the insertion and the settle-to-bottom. It used ChatMotion.swap (bounce 0.31) against a scroll spring set to 0.12 precisely so it wouldn't overshoot. It now uses keyboardScroll — zero bounce, the transcript following bottom chrome, which is the job it is doing.

The bottom inset was dropped rather than deferred. setBottomInset refuses to write mid-batch-update, but it discarded the request instead of holding it, so a bar height landing inside the update left the transcript matching it a layout pass later, as a snap. It now parks in pendingBottomInset and the batch update's completion applies it.

A send rebuilt the whole screen. ComposerModel.clear() wrote mode = .new unconditionally and @Observable fires on assignment without comparing. The chat screen's body reads isEditing and editingStableID, both derived from mode, so every send re-ran updateUIViewController at the frame the insertion started. Guarded to write only on a real change.

The bar's leading control tracked the field's top edge. Top-aligning the bar's row made it travel with every line the draft gained or lost, unanimated — the bar's springs key on chatExists and isEditing, neither of which moves during a send. Back to bottom-aligned.

The row above an insertion slid left. ChatColumnCell pinned one horizontal edge and let the other float, so the column's width was its widest arranged subview — for a short message, the receipt line rather than the bubble. Clearing the receipt shrank the column by 40pt and slid its origin the same distance; the bubble only looked still because its offset inside the stack moved the opposite way by exactly as much. That cancellation holds on final values, but mid-flight it is three separate layer animations that have to share a curve.

Both edges are pinned now and the stack's alignment hugs the sender's side. Measured at a 402pt row, the stack is (12, 0, 378, h) with and without a receipt, the own bubble's absolute x is unchanged at 347.67, other-sender rows still hug leading at 12, and long text still wraps at the cap. Only the receipt row animates. A full-width column also makes UIKit's default compressed horizontal measurement report the content's width, so the cell now measures height explicitly at the width the layout asked for — a width ChatLayout forces to full for a .fullWidth row anyway.

A send collapses a multiline field, so `setBarHeight` runs at the same moment
as the insertion and the settle-to-bottom. It was using `ChatMotion.swap`,
whose bounce of 0.31 is the second-highest in the vocabulary, against a scroll
spring set to 0.12 precisely so it wouldn't overshoot. Three curves overshooting
the same pixels by different amounts is what read as the bar and the transcript
coming apart. It now uses `keyboardScroll` — the transcript following bottom
chrome, zero bounce — which is the job it is actually doing.

The other half was the inset. `setBottomInset` refuses to write mid-batch-update,
which is right on its own, but it was dropping the request rather than holding it:
a bar height that landed inside the update left the bar animating to a height the
transcript only matched on some later layout pass, as a snap. It now parks in
`pendingBottomInset` — the slot the context-menu freeze already used — and the
batch update's completion applies it.
Two things the edit work added that a plain send now pays for.

`ComposerModel.clear()` wrote `mode = .new` unconditionally, and `@Observable`
fires its registrar on assignment without comparing. The chat screen's body reads
`isEditing` and `editingStableID`, both derived from `mode`, so every send
rebuilt the whole screen — toolbar included — and re-ran `updateUIViewController`
at the frame the insertion started. Before the edit work, clearing the draft
touched only the composer subtree. Guarded, so the write happens when the value
actually changes.

The bar's row went top-aligned, which made the leading control track the field's
top edge and 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 the control snapped down while the bar's height
sprang underneath it. Back to bottom-aligned, against the bar's own pinned bottom.
The column pinned one horizontal edge and let the other float, so its width
was its widest arranged subview. For a short message that was the receipt
line, not the bubble: clearing the receipt shrank the column by 40pt and
slid its origin the same distance. The bubble looked still only because its
offset inside the stack moved the opposite way by exactly as much.

That cancellation holds on final values. Mid-flight it is three separate
layer animations — stack bounds, stack position, bubble position — and any
one off the others' curve slides the bubble sideways, which is the leftward
shift on the row above an insertion.

Pin both edges and let the stack's alignment hug the sender's side. The
stack measures (12, 0, 378, h) with and without a receipt, and the bubble's
absolute x is unchanged at 347.67, so only the receipt row animates.

Self-sizing needs an explicit fit now: a full-width column makes UIKit's
default compressed horizontal measurement report the content's width, which
ChatLayout discards anyway for a .fullWidth row. Measure height at the width
the layout asked for instead.
@bmc08gt bmc08gt self-assigned this Sep 3, 2026
@bmc08gt
bmc08gt merged commit 41f4d3a into main Sep 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant