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:
@@ -105,9 +105,8 @@ const useNativeMobileChrome = (): void => {
|
||||
// Marks the Capacitor shell so keyboard-inset CSS only applies here, not in
|
||||
// the browser-hosted PWA (which handles the keyboard via dvh / interactive-widget).
|
||||
root.classList.add('oc-capacitor-app');
|
||||
// Platform marker: Android resizes the window for the keyboard (no manual inset), so the
|
||||
// shell's height transition (meant for iOS's animated --oc-keyboard-inset) must be off there
|
||||
// — otherwise the height animates against the instant native resize and the header bounces.
|
||||
// Platform marker: Android resizes the window for the keyboard natively (no manual
|
||||
// inset/choreography — the keyboard listeners below skip Android entirely).
|
||||
const capacitorPlatform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
||||
if (capacitorPlatform === 'android') {
|
||||
root.classList.add('oc-platform-android');
|
||||
@@ -168,35 +167,151 @@ const useNativeMobileChrome = (): void => {
|
||||
// counts and floats the composer a keyboard-height above the keyboard — skip it there.
|
||||
const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
||||
if (platform === 'android') return;
|
||||
await Keyboard.setAccessoryBarVisible({ isVisible: true }).catch(() => undefined);
|
||||
// No WebKit form accessory bar (prev/next arrows + Done) above the keyboard —
|
||||
// there's a single input, so it only eats vertical space.
|
||||
await Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => undefined);
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
const KB_ANIM_MS = 250;
|
||||
// Dismissal reads faster than the rise — run the hide leg shorter (kept in
|
||||
// sync with the .oc-kb-hide transition-duration override in mobile.css).
|
||||
const KB_HIDE_MS = 200;
|
||||
const KB_ANIM_EASING = 'cubic-bezier(0.38, 0.7, 0.125, 1)';
|
||||
let settleTimer: number | null = null;
|
||||
let keyboardHeight = 0;
|
||||
let layoutApplied = false;
|
||||
let safeBottomPx = 0;
|
||||
let keyboardOpen = false;
|
||||
|
||||
const setVar = (name: string, px: number) => {
|
||||
root.style.setProperty(name, `${Math.max(0, Math.round(px))}px`);
|
||||
};
|
||||
const clearSettle = () => {
|
||||
if (settleTimer !== null) {
|
||||
window.clearTimeout(settleTimer);
|
||||
settleTimer = null;
|
||||
}
|
||||
};
|
||||
const dispatchKb = (type: 'oc:keyboard-anim' | 'oc:keyboard-settled', detail: Record<string, unknown>) => {
|
||||
window.dispatchEvent(new CustomEvent(type, { detail }));
|
||||
};
|
||||
|
||||
// `keyboardWillShow` fires at the START of the iOS keyboard animation and
|
||||
// carries the final height, so we set the inset once here and let the CSS
|
||||
// transition (tuned to mimic the iOS keyboard curve/duration) carry the rise.
|
||||
// visualViewport tracking was tried but doesn't shrink under WKWebView's
|
||||
// `resize: 'none'`, so it never reported the keyboard — this event is the
|
||||
// reliable signal.
|
||||
const showHandle = await Keyboard.addListener('keyboardWillShow', (info) => {
|
||||
root.classList.add('oc-keyboard-open');
|
||||
setInset(info.keyboardHeight);
|
||||
clearSettle();
|
||||
keyboardOpen = true;
|
||||
keyboardHeight = info.keyboardHeight;
|
||||
if (!layoutApplied) {
|
||||
// The shell's resolved padding-bottom while the keyboard is down IS the
|
||||
// bottom safe padding it gives up when open — measure it so the slide
|
||||
// distance lands the composer exactly where the final layout puts it.
|
||||
const shell = document.querySelector('.oc-mobile-app-shell');
|
||||
safeBottomPx = shell ? parseFloat(getComputedStyle(shell).paddingBottom) || 0 : 0;
|
||||
}
|
||||
const slide = Math.max(0, keyboardHeight - safeBottomPx);
|
||||
root.classList.remove('oc-kb-hide');
|
||||
root.classList.add('oc-keyboard-open', 'oc-kb-animating');
|
||||
setInset(keyboardHeight);
|
||||
setVar('--oc-kb-shift', slide);
|
||||
dispatchKb('oc:keyboard-anim', { phase: 'show', slide, durationMs: KB_ANIM_MS, easing: KB_ANIM_EASING });
|
||||
settleTimer = window.setTimeout(() => {
|
||||
settleTimer = null;
|
||||
// Invisible swap: transition off, layout takes the keyboard height (one
|
||||
// reflow), shift returns to 0 in the same frame.
|
||||
root.classList.remove('oc-kb-animating');
|
||||
setVar('--oc-kb-layout', keyboardHeight);
|
||||
layoutApplied = true;
|
||||
setVar('--oc-kb-shift', 0);
|
||||
dispatchKb('oc:keyboard-settled', { open: true });
|
||||
}, KB_ANIM_MS + 20);
|
||||
});
|
||||
const hideHandle = await Keyboard.addListener('keyboardWillHide', () => {
|
||||
|
||||
// Shared hide choreography. The bridge's `keyboardWillHide` can arrive a
|
||||
// beat AFTER the native dismiss animation has already started (WKWebView +
|
||||
// resize: 'none'), which made the composer begin its down-slide only once
|
||||
// the keyboard was gone. The earliest reliable signal for the common
|
||||
// dismissal path (tap outside the input) is the textarea's focusout — so
|
||||
// both trigger this, and `keyboardOpen` makes the second call a no-op.
|
||||
const runHide = () => {
|
||||
if (!keyboardOpen) return;
|
||||
keyboardOpen = false;
|
||||
clearSettle();
|
||||
const slide = Math.max(0, keyboardHeight - safeBottomPx);
|
||||
root.classList.remove('oc-keyboard-open');
|
||||
setInset(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
|
||||
// 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);
|
||||
// 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.
|
||||
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);
|
||||
settleTimer = window.setTimeout(() => {
|
||||
settleTimer = null;
|
||||
root.classList.remove('oc-kb-animating', 'oc-kb-hide');
|
||||
dispatchKb('oc:keyboard-settled', { open: false });
|
||||
}, KB_HIDE_MS + 20);
|
||||
};
|
||||
|
||||
const hideHandle = await Keyboard.addListener('keyboardWillHide', runHide);
|
||||
|
||||
// Early hide trigger: blurring the focused text field is what starts the
|
||||
// native dismiss animation, and it happens in-page — no bridge latency.
|
||||
// Deferred a task so a synchronous refocus (focus moving to another text
|
||||
// input, or a control that restores focus) doesn't false-trigger; in that
|
||||
// case the keyboard never hides and `keyboardWillHide` never fires either.
|
||||
const isTextInput = (node: unknown): boolean =>
|
||||
node instanceof HTMLElement
|
||||
&& (node.tagName === 'TEXTAREA' || node.tagName === 'INPUT' || node.isContentEditable);
|
||||
const handleFocusOut = (event: FocusEvent) => {
|
||||
if (!keyboardOpen) return;
|
||||
if (!isTextInput(event.target)) return;
|
||||
if (isTextInput(event.relatedTarget)) return;
|
||||
window.setTimeout(() => {
|
||||
if (!keyboardOpen) return;
|
||||
if (isTextInput(document.activeElement)) return;
|
||||
runHide();
|
||||
}, 0);
|
||||
};
|
||||
document.addEventListener('focusout', handleFocusOut, true);
|
||||
|
||||
if (disposed) {
|
||||
clearSettle();
|
||||
document.removeEventListener('focusout', handleFocusOut, true);
|
||||
void showHandle.remove();
|
||||
void hideHandle.remove();
|
||||
return;
|
||||
}
|
||||
cleanup.push(() => void showHandle.remove(), () => void hideHandle.remove());
|
||||
cleanup.push(
|
||||
clearSettle,
|
||||
() => document.removeEventListener('focusout', handleFocusOut, true),
|
||||
() => void showHandle.remove(),
|
||||
() => void hideHandle.remove(),
|
||||
);
|
||||
}).catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup.forEach((remove) => remove());
|
||||
root.classList.remove('oc-capacitor-app', 'oc-keyboard-open', 'oc-platform-android');
|
||||
root.classList.remove('oc-capacitor-app', 'oc-keyboard-open', 'oc-kb-animating', 'oc-kb-hide', 'oc-platform-android');
|
||||
root.style.removeProperty('--oc-keyboard-inset');
|
||||
root.style.removeProperty('--oc-kb-shift');
|
||||
root.style.removeProperty('--oc-kb-layout');
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -537,31 +537,39 @@
|
||||
|
||||
/* Native (Capacitor) keyboard handling.
|
||||
The Keyboard plugin runs in `resize: 'none'` mode so the WebView keeps its full
|
||||
height; instead we shrink the app shell by the keyboard frame height, exposed as
|
||||
--oc-keyboard-inset and set once from `keyboardWillShow` (see useNativeMobileChrome).
|
||||
height; instead we shrink the app shell by the keyboard frame height. Two vars,
|
||||
both set by useNativeMobileChrome: --oc-keyboard-inset is the keyboard's target
|
||||
height from the moment `keyboardWillShow` fires (overlay surfaces below animate
|
||||
against it as before), while --oc-kb-layout is the same height applied to the
|
||||
shell's LAYOUT only at the choreography point where a resize is invisible.
|
||||
Scoped to .oc-capacitor-app so the browser PWA keeps its dvh / interactive-widget
|
||||
behaviour untouched.
|
||||
|
||||
`keyboardWillShow` fires at the start of the iOS keyboard animation, so the inset
|
||||
is set once and the transition carries the rise. The duration/curve are tuned to
|
||||
mimic the native iOS keyboard (≈0.25s, cubic-bezier(0.38, 0.7, 0.125, 1)) so our
|
||||
layout and the keyboard move together. (visualViewport live-tracking would be exact
|
||||
The shell height is NOT transitioned. Animating `height` reflows the whole shell
|
||||
every frame and forces the chat's ResizeObserver to chase the bottom on each of
|
||||
those frames — the source of the visible micro-jitter on the composer/chat while
|
||||
pinned. Instead the visual rise is transform-only (compositor) — a FLIP
|
||||
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
|
||||
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
|
||||
keyboard), the composer/content are FLIP-offset to their raised position
|
||||
and slide down with the keyboard.
|
||||
|
||||
`keyboardWillShow` fires at the start of the iOS keyboard animation with the final
|
||||
height, and the duration/curve mimic the native keyboard (≈0.25s,
|
||||
cubic-bezier(0.38, 0.7, 0.125, 1)). (visualViewport live-tracking would be exact
|
||||
but doesn't report under WKWebView's `resize: 'none'`, so this is the best signal.) */
|
||||
:root.oc-capacitor-app .oc-mobile-app-shell {
|
||||
height: calc(100dvh - var(--oc-keyboard-inset, 0px));
|
||||
height: calc(100dvh - var(--oc-kb-layout, 0px));
|
||||
/* Reserve the bottom safe area only while the keyboard is down — when it's up the
|
||||
inset cancels it out (the home indicator is hidden and the composer should sit
|
||||
flush above the keyboard). The shell keeps its own bg behind this padding. */
|
||||
padding-bottom: max(0px, calc(var(--oc-app-bottom-safe, 0px) - var(--oc-keyboard-inset, 0px)));
|
||||
transition: height 0.25s cubic-bezier(0.38, 0.7, 0.125, 1),
|
||||
padding-bottom 0.25s cubic-bezier(0.38, 0.7, 0.125, 1);
|
||||
}
|
||||
|
||||
/* Android resizes the window for the keyboard natively (no manual --oc-keyboard-inset),
|
||||
so 100dvh changes instantly. Animating height against that instant resize makes the
|
||||
header/content bounce on keyboard open — disable the transition on Android. */
|
||||
:root.oc-capacitor-app.oc-platform-android .oc-mobile-app-shell {
|
||||
transition: none;
|
||||
padding-bottom: max(0px, calc(var(--oc-app-bottom-safe, 0px) - var(--oc-kb-layout, 0px)));
|
||||
}
|
||||
|
||||
/* Portal surfaces (bottom sheets, overlay panels) render at <body> level, outside
|
||||
@@ -587,13 +595,32 @@
|
||||
transition: height 0.25s cubic-bezier(0.38, 0.7, 0.125, 1);
|
||||
}
|
||||
|
||||
/* The composer keeps its 1rem bottom padding while the keyboard is down (breathing
|
||||
room above the home indicator), but that gap looks artificial sitting above the
|
||||
keyboard's accessory bar — so tighten it while the keyboard is open. Animated to
|
||||
match the keyboard motion. */
|
||||
/* 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 {
|
||||
transition: padding-bottom 0.25s cubic-bezier(0.38, 0.7, 0.125, 1);
|
||||
transform: translateY(calc(-1 * var(--oc-kb-shift, 0px)));
|
||||
}
|
||||
: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;
|
||||
}
|
||||
|
||||
/* The composer keeps its 1rem bottom padding while the keyboard is down (breathing
|
||||
room above the home indicator), but that gap looks artificial sitting right above
|
||||
the keyboard — so tighten it while the keyboard is open. Snaps at the start of the
|
||||
show animation (a ~10px pre-slide change; transitioning it would reflow the chat
|
||||
every frame). */
|
||||
:root.oc-capacitor-app.oc-keyboard-open .oc-mobile-composer {
|
||||
padding-bottom: 6px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user