Files
jrshikoku/lib/useKeyboardAvoid.ts
harukin-expo-dev-env 4017f82b10 fix: キーボード回避をAnimated.timing/springに移行し、高速切替時の位置ずれとアニメーション不動を解消
- 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: 改修資料を追加
2026-04-09 10:41:19 +00:00

234 lines
9.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}