requestAnimationFrameを使ってアニメーションを作ってみる
ちょっと株式会社でフロントエンドエンジニアをしているでんです。
業務の中でrequestAnimationFrame
に触れる機会がありましたので紹介できればと思います。
requestAnimationFrameとは
window.requestAnimationFrame() メソッドは、ブラウザーにアニメーションを行いたいことを知らせ、指定した関数を呼び出して次の再描画の前にアニメーションを更新することを要求します。このメソッドは、再描画の前に呼び出されるコールバック 1 個を引数として取ります。
https://developer.mozilla.org/ja/docs/Web/API/Window/requestAnimationFrame
MDNにある通り、動きのあるものを作りたい時に使えそうですね。
アニメーションのように連続して動かしたい場合は、requestAnimationFrame(callback)
のように引数にcallback関数を渡します。
requestAnimationFrameの特徴として、1フレームに対しての処理という点です。リフレッシュレートが60FPSであれば1秒間に60回requestAnimationFrameに渡したコールバック関数が実行されるということになります。
実装例
コード
import { useEffect, useRef, useState } from "react";
type EmojiProps = {
fontSize: number;
};
const CryEmoji = ({ fontSize }: EmojiProps) => {
return <span className={`text-[${fontSize}px]`}>😢</span>;
};
const SmileEmoji = ({ fontSize }: EmojiProps) => {
return <span className={`text-[${fontSize}px]`}>😃</span>;
};
const FrownEmoji = ({ fontSize }: EmojiProps) => {
return <span className={`text-[${fontSize}px]`}>🙁</span>;
};
const EMOJI_WIDTH = 75;
const TOTAL_EMOJI_WIDTH = EMOJI_WIDTH * 4;
const ROTATION_SPEED = 1;
const STOP_SPEED = ROTATION_SPEED / 15;
const App = () => {
const emojiContentRef = useRef<HTMLDivElement>(null);
const [isRunning, setIsRunning] = useState(false);
const positionX = useRef(0);
const targetStopPosition = useRef<number | null>(null);
const handleRotation = (deltaTime: number) => {
const movement = ROTATION_SPEED * deltaTime;
positionX.current -= movement;
if (positionX.current < -TOTAL_EMOJI_WIDTH + EMOJI_WIDTH) {
positionX.current = 0;
}
return true;
};
const handleStop = (deltaTime: number, emojiContent: HTMLDivElement) => {
const currentPosition = Math.abs(positionX.current);
const targetPosition = targetStopPosition.current!;
const distance = targetPosition - currentPosition;
// 目標位置に十分近づいたら完全停止
if (Math.abs(distance) < 1) {
positionX.current = -targetPosition;
emojiContent.style.transform = `translateX(${positionX.current}px)`;
targetStopPosition.current = null;
return false;
}
const movement = STOP_SPEED * deltaTime;
positionX.current -= movement;
return true;
};
const toggleAnimation = () => {
if (isRunning) {
const currentPosition = Math.abs(positionX.current);
const nextEmojiPosition =
Math.ceil(currentPosition / EMOJI_WIDTH) * EMOJI_WIDTH;
targetStopPosition.current = nextEmojiPosition;
} else {
targetStopPosition.current = null;
}
setIsRunning(!isRunning);
};
useEffect(() => {
let animationFrameId = 0;
let previousTimestamp: number | null = null;
const animate = (timestamp: number) => {
const emojiContent = emojiContentRef.current;
if (!emojiContent) return;
if (!previousTimestamp) {
previousTimestamp = timestamp;
}
const deltaTime = timestamp - previousTimestamp;
previousTimestamp = timestamp;
let shouldContinue = false;
if (!isRunning && targetStopPosition.current !== null) {
shouldContinue = handleStop(deltaTime, emojiContent);
} else if (isRunning) {
shouldContinue = handleRotation(deltaTime);
}
emojiContent.style.transform = `translateX(${positionX.current}px)`;
if (shouldContinue) {
animationFrameId = requestAnimationFrame(animate);
}
};
if (isRunning || targetStopPosition.current !== null) {
animationFrameId = requestAnimationFrame(animate);
}
return () => cancelAnimationFrame(animationFrameId);
}, [isRunning]);
return (
<main className="max-w-[640px] mx-auto my-10 px-5">
<div className="flex justify-center">
<div className="border border-black w-[75px] overflow-hidden">
<div
ref={emojiContentRef}
className="grid grid-cols-4 text-center w-[300px] relative leading-none"
>
<SmileEmoji fontSize={75} />
<CryEmoji fontSize={75} />
<FrownEmoji fontSize={75} />
<SmileEmoji fontSize={75} />
</div>
</div>
</div>
<div className="flex justify-center mt-4">
<button
className="bg-blue-500 text-white px-4 py-2 rounded"
onClick={toggleAnimation}
>
{isRunning ? "アニメーションを停止" : "アニメーションをスタート"}
</button>
</div>
</main>
);
};
export default App;
部分的に解説
if (isRunning || targetStopPosition.current !== null) {
animationFrameId = requestAnimationFrame(animate);
}
アニメーションを担うanimate
をrequestAnimationFrameに渡すことで動きます。
次のフレームも動かしたい条件の時にanimate
の中でrequestAnimationFrame(animate)
を呼ぶと連続して動かし続けることができます。
const handleRotation = (deltaTime: number) => {
const movement = ROTATION_SPEED * deltaTime;
positionX.current -= movement;
if (positionX.current < -TOTAL_EMOJI_WIDTH + EMOJI_WIDTH) {
positionX.current = 0;
}
return true;
};
handleRotation
が1フレームあたりどれくらい動かすかを求めています。
positionX.current -= 10;
のように固定値を入れると、リフレッシュレートによって速度が変わります。
リフレッシュレートに左右されずに速度を保ちたい場合はdeltaTime
のように前回のフレームから今回のフレームまでの経過時間を計算して使うことになるかと思います。
return () => cancelAnimationFrame(animationFrameId);
最後にクリーンアップ処理にcancelAnimationFrame
を使ってスケジュールされたアニメーションフレームリクエストをキャンセルしておきましょう。
まとめ
requestAnimationFrameを使ったアニメーションを紹介させていただきました。
再生、停止の他、停止後に別のアニメーションを追加してより楽しいアニメーション作りができるといいですね!

ちょっと株式会社(chot-inc.com)のエンジニアブログです。 フロントエンドエンジニア募集中! カジュアル面接申し込みはこちらから chot-inc.com/recruit/iuj62owig
Discussion