fix(chat): improve in-app keyboard avoidance
This commit is contained in:
@@ -1,70 +1,212 @@
|
||||
"use client";
|
||||
/**
|
||||
* 软键盘高度 hook(修复 Facebook IAB / 小米 WebView 不收缩 100dvh 导致输入框被遮挡)
|
||||
* 软键盘避让 hook。
|
||||
*
|
||||
* 通过 window.visualViewport 的 resize/scroll 事件计算键盘高度:
|
||||
* keyboardHeight = max(0, innerHeight - (visualViewport.height + offsetTop))
|
||||
* 写到 <html> 的 CSS 变量 --keyboard-height(默认 0px)。
|
||||
* 不返回 React state,避免键盘动画期间触发 re-render。
|
||||
* rAF 合并写入;卸载时清理监听并把变量重置为 0px,防止路由切换后状态泄漏。
|
||||
* 目标环境:Facebook IAB / 小米 WebView 等软键盘覆盖页面但 viewport 信号不稳定的浏览器。
|
||||
*
|
||||
* 行为:
|
||||
* - iOS Safari / 现代 Chrome Android:visualViewport.height 随键盘收缩,差值 ≈ 0,
|
||||
* padding 不变,无视觉副作用。
|
||||
* - FB IAB / 小米等不收缩 innerHeight 的 WebView:差值即为键盘高度,CSS 即可适配。
|
||||
* - visualViewport 不可用:降级为 0(保留原行为,不引入新问题)。
|
||||
* 策略:
|
||||
* 1. 持续写入 --app-viewport-height,聊天页高度不再只依赖 100dvh。
|
||||
* 2. 优先使用 visualViewport 计算键盘高度。
|
||||
* 3. 输入框聚焦后测量目标元素是否越过可见底部,计算额外抬升。
|
||||
* 4. Android 应用内浏览器若 viewport 未可靠变化,启用保守 fallback 抬升。
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { type RefObject, useEffect, useRef } from "react";
|
||||
|
||||
const VAR_NAME = "--keyboard-height";
|
||||
import { BrowserDetector, Logger } from "@/utils";
|
||||
|
||||
function compute(): number {
|
||||
if (typeof window === "undefined") return 0;
|
||||
const vv = window.visualViewport;
|
||||
if (vv) {
|
||||
return Math.max(0, window.innerHeight - (vv.height + vv.offsetTop));
|
||||
}
|
||||
return 0;
|
||||
const KEYBOARD_VAR = "--keyboard-height";
|
||||
const VIEWPORT_VAR = "--app-viewport-height";
|
||||
const INPUT_LIFT_VAR = "--chat-input-lift";
|
||||
const FOCUS_GAP_PX = 14;
|
||||
const FALLBACK_KEYBOARD_RATIO = 0.42;
|
||||
const FALLBACK_KEYBOARD_MIN = 260;
|
||||
const FALLBACK_KEYBOARD_MAX = 380;
|
||||
const RECHECK_DELAYS_MS = [0, 50, 120, 240, 420, 700];
|
||||
const RECHECK_EVENT = "cozsweet:keyboard-recheck";
|
||||
|
||||
const log = new Logger("HooksUseKeyboardHeight");
|
||||
|
||||
export interface UseKeyboardHeightOptions {
|
||||
targetRef?: RefObject<HTMLElement | null>;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export function useKeyboardHeight(): void {
|
||||
export function useKeyboardHeight({
|
||||
targetRef,
|
||||
active = false,
|
||||
}: UseKeyboardHeightOptions = {}): void {
|
||||
const activeRef = useRef(active);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = active;
|
||||
window.dispatchEvent(new Event(RECHECK_EVENT));
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
let rafId: number | null = null;
|
||||
let last = -1;
|
||||
let timeoutIds: number[] = [];
|
||||
let lastKeyboardHeight = -1;
|
||||
let lastViewportHeight = -1;
|
||||
let lastInputLift = -1;
|
||||
|
||||
const apply = () => {
|
||||
rafId = null;
|
||||
const h = Math.round(compute());
|
||||
if (h === last) return;
|
||||
last = h;
|
||||
document.documentElement.style.setProperty(VAR_NAME, `${h}px`);
|
||||
|
||||
const metrics = readViewportMetrics();
|
||||
const keyboardHeight = Math.round(metrics.keyboardHeight);
|
||||
const viewportHeight = Math.round(metrics.visibleHeight);
|
||||
const inputLift = Math.round(
|
||||
activeRef.current
|
||||
? Math.max(
|
||||
computeTargetOverlap(targetRef?.current, metrics.visibleBottom),
|
||||
computeFallbackLift(metrics),
|
||||
)
|
||||
: 0,
|
||||
);
|
||||
|
||||
if (keyboardHeight !== lastKeyboardHeight) {
|
||||
lastKeyboardHeight = keyboardHeight;
|
||||
document.documentElement.style.setProperty(
|
||||
KEYBOARD_VAR,
|
||||
`${keyboardHeight}px`,
|
||||
);
|
||||
}
|
||||
|
||||
if (viewportHeight !== lastViewportHeight) {
|
||||
lastViewportHeight = viewportHeight;
|
||||
document.documentElement.style.setProperty(
|
||||
VIEWPORT_VAR,
|
||||
`${viewportHeight}px`,
|
||||
);
|
||||
}
|
||||
|
||||
if (inputLift !== lastInputLift) {
|
||||
lastInputLift = inputLift;
|
||||
document.documentElement.style.setProperty(
|
||||
INPUT_LIFT_VAR,
|
||||
`${inputLift}px`,
|
||||
);
|
||||
log.debug("[keyboard] apply", {
|
||||
active: activeRef.current,
|
||||
keyboardHeight,
|
||||
viewportHeight,
|
||||
inputLift,
|
||||
visualViewport: metrics.hasVisualViewport,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
if (rafId != null) return;
|
||||
rafId = window.requestAnimationFrame(apply);
|
||||
};
|
||||
|
||||
const scheduleRechecks = () => {
|
||||
timeoutIds.forEach((id) => window.clearTimeout(id));
|
||||
timeoutIds = RECHECK_DELAYS_MS.map((delay) =>
|
||||
window.setTimeout(schedule, delay),
|
||||
);
|
||||
};
|
||||
|
||||
const vv = window.visualViewport;
|
||||
if (vv) {
|
||||
vv.addEventListener("resize", schedule);
|
||||
vv.addEventListener("scroll", schedule);
|
||||
} else {
|
||||
window.addEventListener("resize", schedule);
|
||||
vv.addEventListener("resize", scheduleRechecks);
|
||||
vv.addEventListener("scroll", scheduleRechecks);
|
||||
}
|
||||
window.addEventListener("resize", scheduleRechecks);
|
||||
window.addEventListener("orientationchange", scheduleRechecks);
|
||||
window.addEventListener("focusin", scheduleRechecks);
|
||||
window.addEventListener("focusout", scheduleRechecks);
|
||||
window.addEventListener(RECHECK_EVENT, scheduleRechecks);
|
||||
|
||||
apply();
|
||||
scheduleRechecks();
|
||||
|
||||
return () => {
|
||||
if (rafId != null) window.cancelAnimationFrame(rafId);
|
||||
timeoutIds.forEach((id) => window.clearTimeout(id));
|
||||
if (vv) {
|
||||
vv.removeEventListener("resize", schedule);
|
||||
vv.removeEventListener("scroll", schedule);
|
||||
} else {
|
||||
window.removeEventListener("resize", schedule);
|
||||
vv.removeEventListener("resize", scheduleRechecks);
|
||||
vv.removeEventListener("scroll", scheduleRechecks);
|
||||
}
|
||||
document.documentElement.style.setProperty(VAR_NAME, "0px");
|
||||
window.removeEventListener("resize", scheduleRechecks);
|
||||
window.removeEventListener("orientationchange", scheduleRechecks);
|
||||
window.removeEventListener("focusin", scheduleRechecks);
|
||||
window.removeEventListener("focusout", scheduleRechecks);
|
||||
window.removeEventListener(RECHECK_EVENT, scheduleRechecks);
|
||||
resetVars();
|
||||
};
|
||||
}, []);
|
||||
}, [targetRef]);
|
||||
}
|
||||
|
||||
interface ViewportMetrics {
|
||||
hasVisualViewport: boolean;
|
||||
layoutHeight: number;
|
||||
visibleHeight: number;
|
||||
visibleBottom: number;
|
||||
keyboardHeight: number;
|
||||
}
|
||||
|
||||
function readViewportMetrics(): ViewportMetrics {
|
||||
const layoutHeight = window.innerHeight;
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) {
|
||||
return {
|
||||
hasVisualViewport: false,
|
||||
layoutHeight,
|
||||
visibleHeight: layoutHeight,
|
||||
visibleBottom: layoutHeight,
|
||||
keyboardHeight: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const visibleHeight = vv.height;
|
||||
const visibleBottom = vv.offsetTop + vv.height;
|
||||
return {
|
||||
hasVisualViewport: true,
|
||||
layoutHeight,
|
||||
visibleHeight,
|
||||
visibleBottom,
|
||||
keyboardHeight: Math.max(0, layoutHeight - visibleBottom),
|
||||
};
|
||||
}
|
||||
|
||||
function computeTargetOverlap(
|
||||
target: HTMLElement | null | undefined,
|
||||
visibleBottom: number,
|
||||
): number {
|
||||
if (!target) return 0;
|
||||
const rect = target.getBoundingClientRect();
|
||||
return Math.max(0, rect.bottom + FOCUS_GAP_PX - visibleBottom);
|
||||
}
|
||||
|
||||
function computeFallbackLift(metrics: ViewportMetrics): number {
|
||||
if (!shouldUseInAppKeyboardFallback()) return 0;
|
||||
|
||||
const viewportAlreadyShrank = metrics.visibleHeight < metrics.layoutHeight * 0.82;
|
||||
const keyboardAlreadyDetected = metrics.keyboardHeight > 80;
|
||||
if (viewportAlreadyShrank || keyboardAlreadyDetected) return 0;
|
||||
|
||||
return clamp(
|
||||
Math.round(metrics.layoutHeight * FALLBACK_KEYBOARD_RATIO),
|
||||
FALLBACK_KEYBOARD_MIN,
|
||||
FALLBACK_KEYBOARD_MAX,
|
||||
);
|
||||
}
|
||||
|
||||
function shouldUseInAppKeyboardFallback(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const ua = navigator.userAgent;
|
||||
return /Android/i.test(ua) && BrowserDetector.isInAppBrowser(ua);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function resetVars(): void {
|
||||
document.documentElement.style.setProperty(KEYBOARD_VAR, "0px");
|
||||
document.documentElement.style.setProperty(VIEWPORT_VAR, "100dvh");
|
||||
document.documentElement.style.setProperty(INPUT_LIFT_VAR, "0px");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user