import { useEffect, useRef, useState } from "react"; import { Animated, Easing, Keyboard, Platform } from "react-native"; interface UseKeyboardAvoidOptions { /** measure()対象のViewのref。指定するとrefの画面座標からオフセットを精密計算する */ measureRef?: React.RefObject; /** iOS用のタブバー高さ(fallback計算時に減算) */ tabBarHeight?: number; } interface UseKeyboardAvoidResult { /** キーボードが表示中か */ keyboardVisible: boolean; /** キーボードの生の高さ */ keyboardHeight: number; /** * Animated.Value によるオフセット(Animated.View の paddingBottom/bottom に使う)。 * measure() 非同期コールバック内から Animated.timing で駆動するため * LayoutAnimation と異なりタイミングを問わず正しくアニメーションする。 */ animatedOffset: Animated.Value; /** * 現在のオフセット数値(Animated.Value を使えない箇所向け)。 * SearchUnitBox など position:absolute で bottom を直接指定する場合に使う。 */ measuredOffset: number; } const ANIM_DURATION = 250; /** * キーボード回避の共通hook。 * - height<=0 の偽イベントをガード、キャッシュで対応 * - iOS: keyboardWillShow/Hide(アニメーション同期)+ keyboardWillChangeFrame * - Android: hide→show 高速切替デバウンス(300ms)+ measure() 150ms 遅延 * - Animated.timing で paddingBottom を駆動(LayoutAnimation 廃止) */ export function useKeyboardAvoid( options: UseKeyboardAvoidOptions = {} ): UseKeyboardAvoidResult { const { measureRef, tabBarHeight = 0 } = options; const [keyboardVisible, setKeyboardVisible] = useState(false); const [keyboardHeight, setKeyboardHeight] = useState(0); const [measuredOffset, setMeasuredOffset] = useState(0); // 再レンダーで Value が作り直されないよう useRef で保持 const animatedOffset = useRef(new Animated.Value(0)).current; const showTimerRef = useRef | null>(null); const hideTimerRef = useRef | null>(null); const retryTimerRef = useRef | null>(null); const keyboardVisibleRef = useRef(false); const lastValidKbRef = useRef<{ height: number; screenY: number; } | null>(null); // 実行中アニメーションの参照(高速切替時に前アニメをキャンセルするため) const currentAnimRef = useRef(null); // 世代カウンタ: hide/show イベント毎にインクリメントし、 // 飛行中の古い measure() コールバックを無効化する const measureGenRef = useRef(0); useEffect(() => { const animateTo = (toValue: number) => { // 前のアニメーションを明示的にキャンセルしてから新しいものを開始 if (currentAnimRef.current) { currentAnimRef.current.stop(); currentAnimRef.current = null; } setMeasuredOffset(toValue); // SearchUnitBox など plain number が必要な箇所向け let anim: Animated.CompositeAnimation; if (Platform.OS === "ios") { // iOS: キーボードの spring アニメーションに近い挙動 anim = Animated.spring(animatedOffset, { toValue, damping: 500, stiffness: 1000, mass: 3, useNativeDriver: false, }); } else { // Android: easeOut で自然な減速カーブ anim = Animated.timing(animatedOffset, { toValue, duration: ANIM_DURATION, easing: Easing.out(Easing.cubic), useNativeDriver: false, }); } currentAnimRef.current = anim; anim.start(({ finished }) => { if (finished) currentAnimRef.current = null; }); }; const doMeasure = (kbScreenY: number, kbHeight: number, gen: number) => { if (measureRef?.current) { (measureRef.current as any).measure( ( _x: number, _y: number, _w: number, h: number, _pageX: number, pageY: number ) => { // 世代が変わっていれば hide/show が割り込んだ証拠 → 破棄 if (gen !== measureGenRef.current) return; const bottomY = pageY + h; const offset = Math.max(0, bottomY - kbScreenY); // measure() コールバック内から直接 Animated.timing を起動 → OK animateTo(offset); } ); } else { const offset = Platform.OS === "ios" ? kbHeight - tabBarHeight : kbHeight; animateTo(offset); } }; const showEventName = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow"; const hideEventName = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide"; const showSubscription = Keyboard.addListener(showEventName, (e) => { if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null; } if (showTimerRef.current) clearTimeout(showTimerRef.current); if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; } // height <= 0 の偽イベントは無視してキャッシュを使う const isValid = e.endCoordinates.height > 0; const kbInfo = isValid ? { height: e.endCoordinates.height, screenY: e.endCoordinates.screenY, } : lastValidKbRef.current; if (!kbInfo) return; if (isValid) lastValidKbRef.current = kbInfo; setKeyboardVisible(true); keyboardVisibleRef.current = true; setKeyboardHeight(kbInfo.height); if (Platform.OS === "android") { // Android: IME が完全に表示されてから measure() する // 世代をインクリメントしてから timer に渡す // → timer 発火後に飛行中の measure() callback を世代で識別できる const gen = ++measureGenRef.current; showTimerRef.current = setTimeout( () => doMeasure(kbInfo.screenY, kbInfo.height, gen), 150 ); // adjustResize のウィンドウリサイズは非同期で 250-300ms かかる。 // 閉じ→すぐ開き の場合、150ms では中間座標を拾うことがあるため // 500ms 後にリトライして自動訂正する。gen チェックで陳腐化コールバックは破棄される。 retryTimerRef.current = setTimeout( () => doMeasure(kbInfo.screenY, kbInfo.height, gen), 500 ); } else { // iOS: keyboardWillShow のタイミングで開始すればキーボード出現と同期する const gen = ++measureGenRef.current; doMeasure(kbInfo.screenY, kbInfo.height, gen); } }); const hideSubscription = Keyboard.addListener(hideEventName, () => { if (showTimerRef.current) clearTimeout(showTimerRef.current); if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; } // timer 発火済みで measure() が飛行中の場合はタイマークリアでは止められない。 // 世代をインクリメントすることで、コールバックが返っても破棄させる。 measureGenRef.current++; // Android: IME切替時の hide→show 連続発火に備えて 300ms debounce // iOS: 50ms のバッファを設ける(即 0ms だと rapid close→open で animateTo(0) が // 先に走りパディングが一瞬ゼロになる cosmetic 問題を回避) const delay = Platform.OS === "android" ? 300 : 50; hideTimerRef.current = setTimeout(() => { setKeyboardVisible(false); keyboardVisibleRef.current = false; setKeyboardHeight(0); animateTo(0); }, delay); }); // iOS のみ: キーボード表示中のサイズ変化(絵文字切替等)に追従 let frameChangeSubscription: ReturnType< typeof Keyboard.addListener > | null = null; if (Platform.OS === "ios") { frameChangeSubscription = Keyboard.addListener( "keyboardWillChangeFrame", (e) => { if (!keyboardVisibleRef.current) return; const kbHeight = e.endCoordinates.height; if (kbHeight <= 0) return; lastValidKbRef.current = { height: kbHeight, screenY: e.endCoordinates.screenY, }; setKeyboardHeight(kbHeight); const gen = ++measureGenRef.current; doMeasure(e.endCoordinates.screenY, kbHeight, gen); } ); } return () => { if (showTimerRef.current) clearTimeout(showTimerRef.current); if (hideTimerRef.current) clearTimeout(hideTimerRef.current); if (retryTimerRef.current) clearTimeout(retryTimerRef.current); if (currentAnimRef.current) currentAnimRef.current.stop(); showSubscription.remove(); hideSubscription.remove(); frameChangeSubscription?.remove(); }; }, [measureRef, tabBarHeight]); return { keyboardVisible, keyboardHeight, animatedOffset, measuredOffset }; }