feat(mobile): transform-based keyboard choreography on iOS

Stop animating the app shell height when the keyboard opens: per-frame
reflow plus the scroll-follow chase caused visible micro-jitter on the
composer and pinned chat. The shell layout now snaps exactly once per
open/close at an invisible choreography point, while the composer and
pinned chat content slide via compositor-only transforms in sync with
the keyboard. Dismissal starts from the textarea focusout (no bridge
latency), runs a shorter 0.2s leg, and the WebKit form accessory bar
is disabled. Composer keeps a 12px gap above the open keyboard.
This commit is contained in:
Bohdan Triapitsyn
2026-07-04 13:55:12 +03:00
parent de1b85ac56
commit 531039b690
3 changed files with 281 additions and 39 deletions
+100
View File
@@ -199,6 +199,12 @@ export const useChatAutoFollow = ({
// ANIMATION_GUARD_MS). 0 = no animation guard active.
const animationGuardUntilRef = React.useRef(0);
// True while the native (Capacitor iOS) keyboard slide choreography is in
// flight (between 'oc:keyboard-anim' and 'oc:keyboard-settled' from
// useNativeMobileChrome). During that window the pinned content is moved by a
// transform on the inner wrapper, so the ResizeObserver chase must stand down.
const keyboardAnimRef = React.useRef(false);
// Last observed scrollTop, used to derive scroll DIRECTION in the scroll
// handler so the bottom-zone re-engage only fires when arriving at the bottom
// by scrolling down — never when a user scrolling UP merely lands in the zone.
@@ -668,6 +674,13 @@ export const useChatAutoFollow = ({
if (!container || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
// Keyboard slide in flight: the container/composer resizes it reports
// are part of the transform choreography — the settle handler does the
// single deterministic re-pin, so chasing here would just fight it.
if (keyboardAnimRef.current) {
updateOverflowAndButton();
return;
}
const el = scrollRef.current;
if (el && !canScroll(el)) {
setStateValue('following');
@@ -703,6 +716,93 @@ 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.
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;
keyboardAnimRef.current = true;
// The clamp/resize during the choreography can dispatch scroll events
// 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;
inner.style.transition = transition;
inner.style.transform = `translateY(${-detail.slide}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 = () => {
keyboardAnimRef.current = false;
const el = scrollRef.current;
if (!el) {
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
// change, not content growth.)
if (stateRef.current === 'following' && canScroll(el)) {
scrollToBottomNow('auto');
}
updateOverflowAndButton();
};
window.addEventListener('oc:keyboard-anim', handleKeyboardAnim);
window.addEventListener('oc:keyboard-settled', handleKeyboardSettled);
return () => {
window.removeEventListener('oc:keyboard-anim', handleKeyboardAnim);
window.removeEventListener('oc:keyboard-settled', handleKeyboardSettled);
keyboardAnimRef.current = false;
};
}, [scrollToBottomNow, updateOverflowAndButton]);
React.useEffect(() => {
updateOverflowAndButton();
}, [sessionMessageCount, updateOverflowAndButton]);