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
+50 -23
View File
@@ -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;
}