From 531039b690a44f6a8911c3534580195d1df304c4 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 4 Jul 2026 13:55:12 +0300 Subject: [PATCH] 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. --- packages/ui/src/apps/MobileApp.tsx | 147 ++++++++++++++++++--- packages/ui/src/hooks/useChatAutoFollow.ts | 100 ++++++++++++++ packages/ui/src/styles/mobile.css | 73 ++++++---- 3 files changed, 281 insertions(+), 39 deletions(-) diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 825b523d..93a48f08 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -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) => { + 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'); }; }, []); }; diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index f8730530..998a9722 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -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]); diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index 8c2a3d29..7b2c7c1d 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -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 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; }