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:
@@ -210,7 +210,7 @@ const useNativeMobileChrome = (): void => {
|
||||
// Keyboard slide choreography (see the "Native (Capacitor) keyboard handling"
|
||||
// block in mobile.css for the full picture). `keyboardWillShow` fires at the
|
||||
// START of the iOS keyboard animation and carries the final height; the
|
||||
// visible motion is transform-only (--oc-kb-shift), and the shell's layout
|
||||
// visible motion is transform-only (inline styles on the kb-movers), and the shell's layout
|
||||
// height (--oc-kb-layout) snaps exactly once per open/close at the moment the
|
||||
// resize is invisible. visualViewport tracking was tried but doesn't shrink
|
||||
// under WKWebView's `resize: 'none'`, so these events are the reliable signal.
|
||||
@@ -238,6 +238,26 @@ const useNativeMobileChrome = (): void => {
|
||||
const dispatchKb = (type: 'oc:keyboard-intent' | 'oc:keyboard-anim' | 'oc:keyboard-settled', detail: Record<string, unknown>) => {
|
||||
window.dispatchEvent(new CustomEvent(type, { detail }));
|
||||
};
|
||||
// Elements that ride the keyboard slide, with their travel factor. Driven
|
||||
// by INLINE styles from here: WebKit does not reliably start a transition
|
||||
// when the transform's value changes via a CSS custom property, which
|
||||
// left the composer parked until the keyboard finished.
|
||||
const getKbMovers = (): Array<{ el: HTMLElement; factor: number }> => {
|
||||
const movers: Array<{ el: HTMLElement; factor: number }> = [];
|
||||
const composer = document.querySelector<HTMLElement>('.oc-mobile-composer');
|
||||
if (composer) movers.push({ el: composer, factor: 1 });
|
||||
// The centered draft title moves half the shift — exactly where the
|
||||
// center lands after the shell snap (see mobile.css notes).
|
||||
const draftCenter = document.querySelector<HTMLElement>('.oc-draft-center');
|
||||
if (draftCenter) movers.push({ el: draftCenter, factor: 0.5 });
|
||||
return movers;
|
||||
};
|
||||
const clearKbMovers = () => {
|
||||
for (const { el } of getKbMovers()) {
|
||||
el.style.transition = '';
|
||||
el.style.transform = '';
|
||||
}
|
||||
};
|
||||
|
||||
const showHandle = await Keyboard.addListener('keyboardWillShow', (info) => {
|
||||
clearSettle();
|
||||
@@ -262,7 +282,18 @@ const useNativeMobileChrome = (): void => {
|
||||
}
|
||||
root.classList.add('oc-keyboard-open', 'oc-kb-animating', 'oc-kb-caret-hold');
|
||||
setInset(keyboardHeight);
|
||||
setVar('--oc-kb-shift', slide);
|
||||
for (const { el, factor } of getKbMovers()) {
|
||||
el.style.transition = `transform ${KB_ANIM_MS}ms ${KB_ANIM_EASING}`;
|
||||
el.style.transform = `translateY(${-slide * factor}px)`;
|
||||
}
|
||||
// Reserve the keyboard strip inside the chat scroller NOW and re-pin
|
||||
// immediately (settled = one cheap scrollTop write over already-mounted
|
||||
// rows), so the chat bottom moves as the keyboard STARTS rising instead
|
||||
// of waiting for it to finish. `slide` (keyboard minus the safe inset
|
||||
// the shell gives up) is exactly the strip the scroller loses at
|
||||
// settle, so pin position and settle stay geometry-neutral.
|
||||
setVar('--oc-kb-scroll-inset', slide);
|
||||
dispatchKb('oc:keyboard-settled', { open: true });
|
||||
dispatchKb('oc:keyboard-anim', { phase: 'show', slide, durationMs: KB_ANIM_MS, easing: KB_ANIM_EASING });
|
||||
settleTimer = window.setTimeout(() => {
|
||||
settleTimer = null;
|
||||
@@ -271,7 +302,7 @@ const useNativeMobileChrome = (): void => {
|
||||
root.classList.remove('oc-kb-animating');
|
||||
setVar('--oc-kb-layout', keyboardHeight);
|
||||
layoutApplied = true;
|
||||
setVar('--oc-kb-shift', 0);
|
||||
clearKbMovers();
|
||||
dispatchKb('oc:keyboard-settled', { open: true });
|
||||
// Reveal the caret only after UIKit's own caret reposition window.
|
||||
caretTimer = window.setTimeout(() => {
|
||||
@@ -304,26 +335,34 @@ const useNativeMobileChrome = (): void => {
|
||||
const slide = Math.max(0, keyboardHeight - safeBottomPx);
|
||||
root.classList.remove('oc-keyboard-open');
|
||||
setInset(0);
|
||||
setVar('--oc-kb-scroll-inset', 0);
|
||||
if (layoutApplied) {
|
||||
// Settled-open → restore the full-height layout NOW (still hidden behind
|
||||
// the keyboard) and FLIP the composer to its raised position without
|
||||
// the keyboard) and FLIP the movers to their raised position without
|
||||
// transitioning, so the next frame looks unchanged.
|
||||
root.classList.remove('oc-kb-animating');
|
||||
setVar('--oc-kb-layout', 0);
|
||||
layoutApplied = false;
|
||||
setVar('--oc-kb-shift', slide);
|
||||
for (const { el, factor } of getKbMovers()) {
|
||||
el.style.transition = 'none';
|
||||
el.style.transform = `translateY(${-slide * factor}px)`;
|
||||
}
|
||||
// Force the style/layout flush so the transition below starts from the
|
||||
// FLIP position instead of coalescing both writes into one frame.
|
||||
void (document.querySelector('.oc-mobile-app-shell') as HTMLElement | null)?.offsetHeight;
|
||||
}
|
||||
// If the hide interrupted a show mid-animation (layout not applied yet),
|
||||
// the shift transitions back down from wherever it currently is.
|
||||
// the movers transition back down from wherever they currently are.
|
||||
dispatchKb('oc:keyboard-anim', { phase: 'hide', slide, durationMs: KB_HIDE_MS, easing: KB_ANIM_EASING });
|
||||
root.classList.add('oc-kb-animating', 'oc-kb-hide');
|
||||
setVar('--oc-kb-shift', 0);
|
||||
for (const { el } of getKbMovers()) {
|
||||
el.style.transition = `transform ${KB_HIDE_MS}ms ${KB_ANIM_EASING}`;
|
||||
el.style.transform = 'translateY(0px)';
|
||||
}
|
||||
settleTimer = window.setTimeout(() => {
|
||||
settleTimer = null;
|
||||
root.classList.remove('oc-kb-animating', 'oc-kb-hide');
|
||||
clearKbMovers();
|
||||
dispatchKb('oc:keyboard-settled', { open: false });
|
||||
}, KB_HIDE_MS + 20);
|
||||
};
|
||||
@@ -378,6 +417,7 @@ const useNativeMobileChrome = (): void => {
|
||||
root.style.removeProperty('--oc-keyboard-inset');
|
||||
root.style.removeProperty('--oc-kb-shift');
|
||||
root.style.removeProperty('--oc-kb-layout');
|
||||
root.style.removeProperty('--oc-kb-scroll-inset');
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
@@ -716,24 +716,22 @@ export const useChatAutoFollow = ({
|
||||
return () => observer.disconnect();
|
||||
}, [armEntryStickQuiet, containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]);
|
||||
|
||||
// ── native keyboard slide (Capacitor iOS choreography) ──────────────────
|
||||
// useNativeMobileChrome (MobileApp) drives the keyboard open/close as a
|
||||
// transform-only slide: during the animation the shell layout does NOT change
|
||||
// (show) or changes exactly once up-front (hide). Our job here:
|
||||
// show: if pinned, slide the inner content up in sync with the composer.
|
||||
// hide: the shell snaps back to full height first, which makes the browser
|
||||
// clamp scrollTop — measure that clamp and FLIP the inner content so
|
||||
// the frame looks unchanged, then let it slide down with the keyboard.
|
||||
// settled: drop the transforms and do ONE instant re-pin in the same frame
|
||||
// (invisible — this runs before the post-reflow paint).
|
||||
// These events never fire outside the Capacitor iOS app, so the listeners are
|
||||
// inert everywhere else.
|
||||
// ── native keyboard transitions (Capacitor choreography) ────────────────
|
||||
// The chat scroller gets NO transforms during the keyboard transition:
|
||||
// transforming the scroll container (or its content) forces WebKit to
|
||||
// rebuild the composited scrolling layers, which stalls for seconds on
|
||||
// long chats. Instead the chat repositions with instant snaps that hide
|
||||
// behind the keyboard itself:
|
||||
// show: content stays put while the keyboard/composer slide over it; the
|
||||
// settled event (shell layout snap) does ONE instant re-pin.
|
||||
// hide: the shell layout is restored up-front — the scrollTop clamp
|
||||
// happens while the keyboard still covers that region — and the
|
||||
// settled event re-pins once at the end.
|
||||
// During the window we only guard the scroll heuristics and the observer
|
||||
// chase. These events never fire outside the Capacitor app.
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const innerOf = (el: HTMLElement): HTMLElement | null =>
|
||||
el.firstElementChild instanceof HTMLElement ? el.firstElementChild : null;
|
||||
|
||||
const handleKeyboardAnim = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ phase: 'show' | 'hide'; slide: number; durationMs: number; easing: string }>).detail;
|
||||
if (!detail) return;
|
||||
@@ -742,39 +740,6 @@ export const useChatAutoFollow = ({
|
||||
// that land away from the auto marker — never read those as a user
|
||||
// scroll-away.
|
||||
animationGuardUntilRef.current = now() + detail.durationMs + ANIMATION_GUARD_MS;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const inner = innerOf(el);
|
||||
if (!inner) return;
|
||||
const transition = `transform ${detail.durationMs}ms ${detail.easing}`;
|
||||
|
||||
if (detail.phase === 'show') {
|
||||
// Only pinned content rides the keyboard; a user reading history
|
||||
// stays put (the composer slides over the bottom, like native apps).
|
||||
if (stateRef.current !== 'following' || !canScroll(el)) return;
|
||||
// Fold any pending re-pin distance into the same slide. The pill
|
||||
// composer swaps to the full composer right before focusing, which
|
||||
// shrinks the viewport without moving scrollTop — compensating it
|
||||
// here makes keyboard + composer growth one motion instead of two.
|
||||
const pending = Math.max(0, el.scrollHeight - el.scrollTop - el.clientHeight);
|
||||
inner.style.transition = transition;
|
||||
inner.style.transform = `translateY(${-(detail.slide + pending)}px)`;
|
||||
return;
|
||||
}
|
||||
|
||||
// hide: the layout var was restored just before this event — force the
|
||||
// reflow now and measure how far the scrollTop clamp moved the content,
|
||||
// then FLIP it back and slide to 0.
|
||||
const before = el.scrollTop;
|
||||
void el.clientHeight;
|
||||
const clamped = before - el.scrollTop;
|
||||
if (clamped > 0.5) {
|
||||
inner.style.transition = 'none';
|
||||
inner.style.transform = `translateY(${-clamped}px)`;
|
||||
void inner.offsetHeight;
|
||||
}
|
||||
inner.style.transition = transition;
|
||||
inner.style.transform = 'translateY(0px)';
|
||||
};
|
||||
|
||||
const handleKeyboardSettled = () => {
|
||||
@@ -784,11 +749,6 @@ export const useChatAutoFollow = ({
|
||||
updateOverflowAndButton();
|
||||
return;
|
||||
}
|
||||
const inner = innerOf(el);
|
||||
if (inner) {
|
||||
inner.style.transition = '';
|
||||
inner.style.transform = '';
|
||||
}
|
||||
// Single deterministic re-pin, same task as the layout swap → lands
|
||||
// before paint. (scrollToBottomNow, not scrollToBottom: this must not
|
||||
// be gated on working/settling — the keyboard resize is a viewport
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user