- LayoutAnimation.configureNext → Animated.timing/spring に全面移行 - iOS: Animated.spring でキーボードアニメーションに追従 - Android: Animated.timing + Easing.out(cubic) で自然な減速カーブ - measureGenRef 世代カウンタで飛行中の古い measure() コールバックを無効化 - retryTimerRef (500ms) で adjustResize の中間座標を自動訂正 - currentAnimRef で高速切替時に前アニメをキャンセル - AllTrainDiagramView / StationDiagramView を Animated.View 化 - docs: 改修資料を追加
234 lines
9.2 KiB
TypeScript
234 lines
9.2 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
||
import { Animated, Easing, Keyboard, Platform } from "react-native";
|
||
|
||
interface UseKeyboardAvoidOptions {
|
||
/** measure()対象のViewのref。指定するとrefの画面座標からオフセットを精密計算する */
|
||
measureRef?: React.RefObject<any>;
|
||
/** 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<ReturnType<typeof setTimeout> | null>(null);
|
||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const keyboardVisibleRef = useRef(false);
|
||
const lastValidKbRef = useRef<{
|
||
height: number;
|
||
screenY: number;
|
||
} | null>(null);
|
||
// 実行中アニメーションの参照(高速切替時に前アニメをキャンセルするため)
|
||
const currentAnimRef = useRef<Animated.CompositeAnimation | null>(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 };
|
||
}
|