perf(mobile): constant-viewport chat scroller across keyboard transitions

Transforms on the chat scroller (content or container) forced WebKit to
rebuild its composited scrolling layers — a multi-second stall on long
chats. The scroller now gets no transforms at all: it keeps a constant
client height by extending below its shrunken region (by keyboard minus
the safe inset the shell gives up, so the settle snap is geometry-neutral)
and converting the keyboard strip into its own bottom padding, driven by
--oc-kb-scroll-inset from the very start of the rise. Nothing resizes for
the virtualizer, every reachable row stays mounted, and open/close is a
single cheap scrollTop write: the re-pin happens as the keyboard starts
rising, and the hide clamp lands behind the still-visible keyboard.

The composer and draft title keep sliding with the keyboard, now via
inline transforms set by the choreography — WebKit does not reliably
start transitions when a transform changes through a CSS custom property,
which had parked the composer until the keyboard finished.
This commit is contained in:
Bohdan Triapitsyn
2026-07-05 01:41:44 +03:00
parent b19f65f9b6
commit 2c220f3c52
3 changed files with 97 additions and 92 deletions
+37 -32
View File
@@ -552,8 +552,8 @@
choreography driven by useNativeMobileChrome:
show: shell keeps its full height for the whole 0.25s; the composer (and, when
pinned, the chat content — see useChatAutoFollow) slides up via
--oc-kb-shift in sync with the keyboard; at the end the shell snaps to
pinned, the chat scroll container — see useChatAutoFollow) slides up via
inline transforms in sync with the keyboard; at the end the shell snaps to
its final height (--oc-kb-layout, one reflow) and the shift is removed
in the same frame — visually identical, so the swap is invisible.
hide: the shell snaps back to full height immediately (still hidden behind the
@@ -601,25 +601,41 @@
transition: height 0.25s cubic-bezier(0.38, 0.7, 0.125, 1);
}
/* Keyboard slide choreography (see the shell comment above). --oc-kb-shift is the
transform-only travel distance (keyboard height minus the bottom safe padding the
shell gives up when the keyboard is open). The transition is gated on
.oc-kb-animating so the end-of-animation swap (shift back to 0 while the layout
height takes over) is instant and invisible. */
:root.oc-capacitor-app .oc-mobile-composer {
transform: translateY(calc(-1 * var(--oc-kb-shift, 0px)));
}
/* Keyboard slide choreography: the composer (and the draft title, see
.oc-draft-center note below) are moved with INLINE transform/transition set
by useNativeMobileChrome — WebKit does not reliably start transitions when a
transform changes via a CSS custom property, which parked the composer until
the keyboard finished. Only the sliding backdrop lives here. */
:root.oc-capacitor-app.oc-kb-animating .oc-mobile-composer {
transition: transform 0.25s cubic-bezier(0.38, 0.7, 0.125, 1);
/* While sliding, the composer passes over chat content that hasn't moved (user
scrolled up) — give it an opaque backdrop so text doesn't show through the
form's transparent padding. */
background: var(--background);
}
/* Keyboard dismissal reads faster than the rise — a full 0.25s down-slide feels
laggy, so the hide leg runs shorter. Must match KB_HIDE_MS in useNativeMobileChrome. */
:root.oc-capacitor-app.oc-kb-animating.oc-kb-hide .oc-mobile-composer {
transition-duration: 0.2s;
/* Constant-viewport trick for the chat scroller: when the keyboard settles the
shell shrinks by --oc-kb-layout, but the scroller keeps its full height by
extending that far below its (shrunken) region — behind the composer and the
keyboard — and converts the covered strip into its own bottom padding. For
the virtualizer NOTHING resizes across keyboard transitions: clientHeight is
constant, every row that can become visible is already mounted, and the
open/close reposition is a single cheap scrollTop change (the settle re-pin /
the clamp) instead of a viewport resize that mounts rows with a visible
pause on long chats. */
:root.oc-capacitor-app .oc-mobile-app-shell .chat-scroll {
/* The shell shrinks by the keyboard height BUT also gives up its bottom
safe-area padding at the same moment (see the shell's padding-bottom
formula) — so the scroller's parent only loses (keyboard safe). Extend
by exactly that, or the settle grows clientHeight by the safe inset and
the pinned chat clamps one row down a beat after the keyboard opens. */
bottom: calc(-1 * max(var(--oc-kb-layout, 0px) - var(--oc-app-bottom-safe, 0px), 0px));
/* The padding leg is driven by its own variable so it can be applied at the
very START of the keyboard rise (with an immediate re-pin — the bottom of
the chat moves up as the keyboard begins, not after it finishes), while
the bottom-extension leg still tracks the shell's settle-time shrink. Both
legs equal the keyboard height once settled, so the settle snap is
geometry-neutral for the scroller. */
padding-bottom: var(--oc-kb-scroll-inset, var(--oc-kb-layout, 0px));
}
/* WKWebView draws the text caret as a native layer that ignores CSS transforms:
@@ -649,23 +665,12 @@
display: none;
}
/* The draft title is vertically centered, so it has no scroll-pinning to
compensate the keyboard like the chat does: during the slide the shell keeps
its full height and only snaps shorter at settle, which made the centered
title glide down (starters collapsing) and then JUMP up (shell snap). Ride
the same choreography as the composer: translate the centered block up by
half the keyboard shift (exactly how far the center moves after the snap),
with the transition gated on .oc-kb-animating so the settle swap — shift
back to 0 in the same frame the layout shrinks — is invisible. */
:root.oc-capacitor-app .oc-draft-center {
transform: translateY(calc(-0.5 * var(--oc-kb-shift, 0px)));
}
:root.oc-capacitor-app.oc-kb-animating .oc-draft-center {
transition: transform 0.25s cubic-bezier(0.38, 0.7, 0.125, 1);
}
:root.oc-capacitor-app.oc-kb-animating.oc-kb-hide .oc-draft-center {
transition-duration: 0.2s;
}
/* The draft title (.oc-draft-center) is vertically centered, so it has no
scroll-pinning to compensate the keyboard like the chat does. It rides the
keyboard choreography with an INLINE transform of half the keyboard shift
(exactly how far the center moves after the shell snap) — see the kb-movers
list in useNativeMobileChrome. No CSS rules needed here; the class only
marks the element for the mover query. */
@keyframes oc-composer-morph-fade {
from { opacity: 0; }