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
|
// 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).
|
// the browser-hosted PWA (which handles the keyboard via dvh / interactive-widget).
|
||||||
root.classList.add('oc-capacitor-app');
|
root.classList.add('oc-capacitor-app');
|
||||||
// Platform marker: Android resizes the window for the keyboard (no manual inset), so the
|
// Platform marker: Android resizes the window for the keyboard natively (no manual
|
||||||
// shell's height transition (meant for iOS's animated --oc-keyboard-inset) must be off there
|
// inset/choreography — the keyboard listeners below skip Android entirely).
|
||||||
// — otherwise the height animates against the instant native resize and the header bounces.
|
|
||||||
const capacitorPlatform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
const capacitorPlatform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
||||||
if (capacitorPlatform === 'android') {
|
if (capacitorPlatform === 'android') {
|
||||||
root.classList.add('oc-platform-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.
|
// 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?.();
|
const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
||||||
if (platform === 'android') return;
|
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) => {
|
const showHandle = await Keyboard.addListener('keyboardWillShow', (info) => {
|
||||||
root.classList.add('oc-keyboard-open');
|
clearSettle();
|
||||||
setInset(info.keyboardHeight);
|
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');
|
root.classList.remove('oc-keyboard-open');
|
||||||
setInset(0);
|
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) {
|
if (disposed) {
|
||||||
|
clearSettle();
|
||||||
|
document.removeEventListener('focusout', handleFocusOut, true);
|
||||||
void showHandle.remove();
|
void showHandle.remove();
|
||||||
void hideHandle.remove();
|
void hideHandle.remove();
|
||||||
return;
|
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);
|
}).catch(() => undefined);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
cleanup.forEach((remove) => remove());
|
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-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.
|
// ANIMATION_GUARD_MS). 0 = no animation guard active.
|
||||||
const animationGuardUntilRef = React.useRef(0);
|
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
|
// 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
|
// 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.
|
// 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;
|
if (!container || typeof ResizeObserver === 'undefined') return;
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
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;
|
const el = scrollRef.current;
|
||||||
if (el && !canScroll(el)) {
|
if (el && !canScroll(el)) {
|
||||||
setStateValue('following');
|
setStateValue('following');
|
||||||
@@ -703,6 +716,93 @@ export const useChatAutoFollow = ({
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [armEntryStickQuiet, containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
updateOverflowAndButton();
|
updateOverflowAndButton();
|
||||||
}, [sessionMessageCount, updateOverflowAndButton]);
|
}, [sessionMessageCount, updateOverflowAndButton]);
|
||||||
|
|||||||
@@ -537,31 +537,39 @@
|
|||||||
|
|
||||||
/* Native (Capacitor) keyboard handling.
|
/* Native (Capacitor) keyboard handling.
|
||||||
The Keyboard plugin runs in `resize: 'none'` mode so the WebView keeps its full
|
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
|
height; instead we shrink the app shell by the keyboard frame height. Two vars,
|
||||||
--oc-keyboard-inset and set once from `keyboardWillShow` (see useNativeMobileChrome).
|
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
|
Scoped to .oc-capacitor-app so the browser PWA keeps its dvh / interactive-widget
|
||||||
behaviour untouched.
|
behaviour untouched.
|
||||||
|
|
||||||
`keyboardWillShow` fires at the start of the iOS keyboard animation, so the inset
|
The shell height is NOT transitioned. Animating `height` reflows the whole shell
|
||||||
is set once and the transition carries the rise. The duration/curve are tuned to
|
every frame and forces the chat's ResizeObserver to chase the bottom on each of
|
||||||
mimic the native iOS keyboard (≈0.25s, cubic-bezier(0.38, 0.7, 0.125, 1)) so our
|
those frames — the source of the visible micro-jitter on the composer/chat while
|
||||||
layout and the keyboard move together. (visualViewport live-tracking would be exact
|
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.) */
|
but doesn't report under WKWebView's `resize: 'none'`, so this is the best signal.) */
|
||||||
:root.oc-capacitor-app .oc-mobile-app-shell {
|
: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
|
/* 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
|
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. */
|
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)));
|
padding-bottom: max(0px, calc(var(--oc-app-bottom-safe, 0px) - var(--oc-kb-layout, 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Portal surfaces (bottom sheets, overlay panels) render at <body> level, outside
|
/* 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);
|
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
|
/* Keyboard slide choreography (see the shell comment above). --oc-kb-shift is the
|
||||||
room above the home indicator), but that gap looks artificial sitting above the
|
transform-only travel distance (keyboard height minus the bottom safe padding the
|
||||||
keyboard's accessory bar — so tighten it while the keyboard is open. Animated to
|
shell gives up when the keyboard is open). The transition is gated on
|
||||||
match the keyboard motion. */
|
.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 {
|
: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 {
|
:root.oc-capacitor-app.oc-keyboard-open .oc-mobile-composer {
|
||||||
padding-bottom: 6px;
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user