ツクログネット

【React】矢印キーで要素を歩かせたりジャンプさせたりする

eye catch

Reactを用いてページ上の要素を矢印キーで動かす方法です。

デモ

矢印キーの←↑→を押すと、表示されている赤い要素が左右に移動したり上へジャンプすることが確認できます。

開発環境を構築する

PCにnode.jsをインストール後、コマンドプロンプトやgit bashなどで下記のコマンドを実行して、Reactアプリの開発環境を構築します。

npx create-react-app my-app
cd my-app
npm start

上記のmy-appがプロジェクトのフォルダ名になります。

ディレクトリ構成

src以下を下記の通りにします。現時点でファイルの中身は空です。

my-app
 └── src
     ├── components
     │   ├── App.js
     │   └── Santa.js
     ├── constants
     │   └── santa.js
     ├── containers
     │   └── Santa.js
     ├── contexts
     │   └── app.js    
     ├── hooks
     │   ├── useAnimationFrameCount.js
     │   ├── useDistanceXByPressKey.js
     │   ├── useElementSize.js
     │   └── useJumpHeightByPressKey.js
     ├── index.css
     └── index.js

不変のスタイルを定義

背景色や要素の色やサイズなど変わることのないスタイルをindex.cssに定義します。

index.css

index.css
html,
body {
	height: 100%;
	width: 100%;
}
body {
	display: flex;
	align-items: center;
	justify-content: center;
	margin: 0;
	overflow: hidden;
}
.app {
	background-color: midnightblue;
	height: 320px;
	position: relative;
	width: 480px;
}

.santa {
	background-color: red;
	height: 80px;
  	position: absolute;
	width: 40px;
}

複数のコンポーネントで値の共有を可能にする

通常、Reactではコンポーネントが親コンポーネントの値を受け取るには、親→子→孫→...→孫のようにして、値を必要とするコンポーネントまで中継(バケツリレー)させる必要があります。

そのため、親コンポーネントから対象のコンポーネントの間に位置するコンポーネントたちは、下りてきた値を必要としない場合であっても中継のために一旦受け取らなければなりません。

親コンポーネントの値を必要とするコンポーネントが親コンポーネントの直下に存在すればバケツリレーで済みますが、ものすごく下の階層に存在する場合は面倒です。

そこで、バケツリレーを行わずに各コンポーネントが値を共有できるようにコンテクストを使用します。

1. コンテクストオブジェクトを作成する

コンテクストを使用するにはまずcreateContextでコンテクストオブジェクトを作成します。

cotexts/app.js

export const AppContext = createContext();

2. プロバイダコンポーネントに共有させたい値をセットする

プロバイダコンポーネントを作成して、valueプロパティにコンポーネントたちに共有させたい値をセットします。

<AppContext.Provider 
	value={{
		elementRef
	}}
>
</AppContext.Provider>

このとき、valueプロパティに設定した値を共有するコンポーネントをコンシューマコンポーネントと呼びます。

3. コンシューマコンポーネントで値を受け取る

コンシューマコンポーネントがvalueプロパティに設定した値を受け取るには、まずプロバイダコンシューマコンポーネントをプロバイダコンポーネントの配下に置きます。

<AppContext.Provider 
	value={{
		elementRef
	}}
>
	<SantaContainer
		x={(width / 2) - (50 / 2)}
		y={height - 80}
		initialVelocityX={10}
		initialVelocityY={10}
	/>
</AppContext.Provider>

そして、コンシューマコンポーネント内からuseContextを呼び出すことでvalueプロパティの値を取得することができます。

const appInfo = useContext(AppContext);

カスタムフックを作成する

複数のコンポーネント内に同じ処理を書くことなく、コンポーネント間でロジックを共有可能にするために、カスタムフックを作成します。

カスタムフックとは関数名が必ずuseから始まり、コンポーネントからロジックのみを抽出したJavaScriptの関数です。カスタムフック内では、様々なフックを呼び出すことができます。

要素のサイズを取得するカスタムフック

コンポーネント内で要素の幅と高さを取得するカスタムフックuseElementSizeを作成します。

useElementSize.js

import { useState, useEffect } from 'react';

export const useElementSize = (element) => {
  const [height, setHeight] = useState(0);
  const [width, setWidth] = useState(0);
  
  useEffect(() => {
    const rect = element.current.getBoundingClientRect();
    setHeight(rect.height);
    setWidth(rect.width);
  }, []);
  
  return [width, height];
}

useStateは現在のstateの値とその値を更新するための関数を返します。上記であればheightが前者でsetHeightが後者です。また、上記のようにuseStateは複数呼び出すことができます。

useEffectはコンポーネントのレンダリング後に何らかの処理(副作用)を実行することができます。副作用はuseEffectの第一引数にコールバック関数で渡します。また、第二引数にpropsやstate等の値が入った配列を渡すと、次回レンダリング時にその値が変更されたときのみ副作用を実行させることができます。上記のように空の配列を渡すと、副作用は初回のレンダリング後に一度だけ呼ばれます。

つまり、useElementSizeはコンポーネントのレンダリング後にgetBoundingClientRectからelementのサイズを取得してそれを返す関数です。

現在のアニメーションのフレーム数を取得するカスタムフック

矢印キーの押下の有無をアニメーション処理で1フレーム毎に監視するため、現在のフレーム数を取得するカスタムフックuseAnimationFrameCountを作成します。

useAnimationFrameCount.js

import { useState, useEffect, useCallback, useRef } from 'react';

export const useAnimationFrameCount = () => {
	const [frameCount, setFrameCount] = useState(0);

	useEffect(() => {
		const loop = () => {
			setFrameCount(prevFrameCount => prevFrameCount + 1);
			requestAnimationFrame(loop);
		};

		requestAnimationFrame(loop);
	}, []);

	return frameCount;
}

useAnimationFrameCountを呼び出すと、requestAnimationFrameによってsetFrameCountが連続で呼ばれるため、毎回1増えたframeCountを返します。この毎回異なるframeCountを他のカスタムフック内で呼び出すことで、1フレーム毎のあらゆる状態の確認が可能です。

矢印キーの押下状態を取得するカスタムフック

今回矢印キーで要素が動く方向は、左・右・上(ジャンプ)の3方向です。なので矢印キーの内、←↑→の押下状態を取得するカスタムフックusePressKeyStatusを作成します。

usePressKeyStatus.js

import { useState, useEffect, useCallback } from 'react';

export const usePressKeyStatus = () => {
	const [stateOfPressKey, setStateOfPressKey] = useState({});
	const handleKeyUp = useCallback((e) => {
		const keyCode = e.keyCode;

		if (keyCode === 37) {// left
			setStateOfPressKey(state => ({
				...state,
				left: false
			}));
		} 
		if (keyCode === 39) {//right
			setStateOfPressKey(state => ({
				...state,
				right: false
			}));
		} 
		if (keyCode === 38) {//up
			setStateOfPressKey(state => ({
				...state,
				top: false
			}));
		}
	}, []);

	const handleKeyDown = useCallback((e) => {
		const keyCode = e.keyCode;

		if (keyCode === 37) {// left
			setStateOfPressKey(state => ({
				...state,
				left: true
			}));
		} 
		if (keyCode === 39) {//right
			setStateOfPressKey(state => ({
				...state,
				right: true
			}));
		} 
		if (keyCode === 38) {//up
			setStateOfPressKey(state => ({
				...state,
				top: true
			}));
		}
	}, []);

	useEffect(() => {
		addEventListener('keydown', e => handleKeyDown(e));
		addEventListener('keyup', e => handleKeyUp(e));
	}, []);

	return stateOfPressKey;
}

stateOfPressKeyは現在の矢印キーの押下状態であり、オブジェクトで管理しています。

副作用は、矢印キーを押したとき(keydown)にhandleKeyDown関数が、離したとき(keyup)にhandleKeyUp関数が実行されるように、addEventListenerでイベントリスナーを追加する処理を一度だけ行います。

つまり、このカスタムフックでは矢印キーが押されたときと離されたときにキーコードから←↑→を判断して、例えば↑が押されたときはstateOfPressKeyのtopをtrueに、離されたときはfalseに切り替えて状態を更新します。

矢印キーで左右へ移動中の要素のx座標を取得するカスタムフック

左右の矢印キーを押したときに要素がその方向へ進むように、矢印キーに連動する水平方向の移動量を取得するカスタムフックuseDistanceXByPressKeyを作成します。

useDistanceXByPressKey.js

import { useState, useEffect, useContext } from 'react';
import { usePressKeyStatus } from './usePressKeyStatus';
import { useAnimationFrameCount } from './useAnimationFrameCount';
import { AppContext } from '../components/App';

const useDistanceXByPressKey = (elementRef, initialVelocityX) => {
	const [positionX, setPositionX] = useState(0);
	const stateOfPressKey = usePressKeyStatus();
	const appInfo = useContext(AppContext);
	const frameCount = useAnimationFrameCount();

	useEffect(() => {
		if(!elementRef.current) return;
		
		const rect = elementRef.current.getBoundingClientRect();
		const appRect = appInfo.elementRef.current.getBoundingClientRect();
		
		let velocityX = 0;

		if (stateOfPressKey.left) {
			if (rect.left - appRect.left <= 0) {
				velocityX = 0;
			} else {
				velocityX -= initialVelocityX;
			}
		}

		if (stateOfPressKey.right) {
			if (rect.right - appRect.right >= 0) {
				velocityX = 0;
			} else {
				velocityX += initialVelocityX;
			}
		}

		setPositionX(prevPositionX => prevPositionX + velocityX);
	}, [frameCount]);

	return positionX;
}

矢印キーの←を押すと速度が初速度分減算され、矢印キーの→を押すと初速度分加算されます。この速度をx座標に代入することで左右の移動を可能にしています。初速度initialVelocityXの値を増やすと1回押して進む距離が増えるため、速い移動が可能です。

また、移動中に要素が画面から消えないように、要素が左端に来たときに速度を0にしています。

矢印キーでジャンプ中の要素のy座標を取得するカスタムフック

矢印キーの↑を押したときに要素をジャンプさせるために、ジャンプ中のy座標を取得するカスタムフックuseJumpHeightByPressKeyを作成します。

また、ジャンプに関しては物理の公式を使って重力を考慮したジャンプを表現します。

useJumpHeightByPressKey.js

import { useState, useEffect, useRef, useContext } from 'react';
import { usePressKeyStatus } from './usePressKeyStatus';
import { useAnimationFrameCount } from './useAnimationFrameCount';

export const useJumpHeightByPressKey = (element, velocity, gravity) => {// initialVelocity初速度
	const [positionY, setPositionY] = useState(0);
	const isJumping = useRef(false);
	const elapsedTime = useRef(0);
	const stateOfPressKey = usePressKeyStatus();
	const frameCount = useAnimationFrameCount();

	useEffect(() => {
		if (stateOfPressKey.top) {
			if(!isJumping.current) {//着地しているときのみ
				isJumping.current = true;
				elapsedTime.current = 0;
			}
		}

		let velocityY = (0.5 * gravity * (elapsedTime.current ** 2) - initialVelocityY * elapsedTime.current);

		elapsedTime.current++;

		//positionY > 0
		if(velocityY > 0) {//着地したら
			isJumping.current = false;
		}

		if(isJumping.current) {
			setPositionY(velocityY);
		}else{
			setPositionY(0);
		}
	}, [frameCount]);

	return positionY;
}

ジャンプを表現するために、このカスタムフックの副作用は1フレーム毎に実行されます。

要素が着地している状態で↑矢印キーを押したときに、要素が上昇していきます。ある程度まで上がると重力の影響で下降していきます。これがジャンプから着地までの過程です。
尚、今回は↑矢印キーの連打や長押しをしてもジャンプの高さが上がらないようにしています。

フレーム毎に以下の公式で導き出された値をy座標に代入することで、重力を考慮したジャンプを表現することができます。

y = (0.5 * 重力 * (経過時間^2)) - (一度に進む距離 * 経過時間)

この値は↑矢印キーを押した途端に放物線を描くように1フレーム毎に変化します。初めはどんどん上に向かって小さくなっていきますが、ジャンプの限界地点まで到達すると今度は下に向かって大きくなっていきます。このまま放っておくと画面から消えてしまうので、0になったら着地するようにします。

コンポーネントを作成する

矢印キーで動かすDOM要素のコンポーネントとそれを子に持つルートコンポーネントを作成します。

1-1. 矢印キーで動かすDOM要素のコンポーネントを作成する

矢印キーで動かすDOM要素のコンポーネントを作成します。ここではコンポーネント名を適当にSantaとしています。尚、コンポーネント名はReactがコンポーネントと認識するように必ず大文字で始めます。

components/Santa.js

import React, { useRef, memo, forwardRef } from 'react';

const Santa = memo(forwardRef(({x, y, offsetX, offsetY}, ref) => {
	const style = {
		left: x,
		top: y,
		transform: `translate3d(${offsetX}px, ${offsetY}px, 0)`
	};

	return (
		<div
			className="santa"
			style={style}
			ref={ref}
		>
		</div>
	);
}));

memoでコンポーネントをメモ化しています。

コンポーネントがメモ化されると、次回のレンダリング時にコンポーネントのプロパティの内どれかが前回と異なっているときのみレンダリングされます。これにより無駄なレンダリングを防げます。

コンポーネントをforwardRefでラップすると、親コンポーネントからそのコンポーネントのDOM要素を参照できます。
今回は、次に登場するコンテナコンポーネントからDOM要素を参照しています。

1-2. 1-1のContainerコンポーネントを作成する

先ほど作成したSantaコンポーネントにロジックを組み込みます。その際、Santaコンポーネントからカスタムフックを呼び出したいところですが、Santaコンポーネントはビュー(見た目)だけを持つシンプルな関数にするべきであるため、ロジックはContainerコンポーネントを作成してその中に組み込みます。

containers/SantaContainer.js

import { Santa } from '../components/Santa';
import { gravity } from '../constants';
import { useDistanceXByPressKey } from '../hooks/useDistanceXByPressKey';
import { useJumpHeightByPressKey } from '../hooks/useJumpHeightByPressKey';

export const SantaContainer = ({ x, y, initialVelocityX, initialVelocityY }) => {
	const elementRef = useRef(null);
	const offsetX = useDistanceXByPressKey(elementRef, initialVelocityX);
	const offsetY = useJumpHeightByPressKey(elementRef, initialVelocityY, gravity);

	return (
		<Santa
          x={x}
          y={y}
          offsetX={offsetX}
          offsetY={offsetY}
          ref={elementRef}
        />
	);
};

SantaコンポーネントのoffsetXにuseDistanceXByPressKeyで取得した値をセットすることで、←か→の矢印キーを押すと要素が左右に動くようになります。

また、offsetYにuseJumpHeightByPressKeyで取得した値をセットすることで、↑の矢印キーを押すと要素がジャンプするようになります。

elementRefオブジェクトのcurrentプロパティにはSantaコンポーネントが返すDOM要素の参照が入っています。何故なら先ほども言いましたようにSantaコンポーネントはforwardRefでラップされており、且つref属性にelementRefオブジェクトをセットしたため、親コンポーネント内から取得できるのです。

2-1. ルートコンポーネントを作成する

アプリのルートとなるコンポーネントを作成し、その中に先ほど作成したSantaContainerコンポーネントを配置します。

components/App.js

import React, { useRef } from 'react';
import { useElementSize } from '../hooks/useElementSize';
import { Santa, SantaContainer } from './Santa';
import { AppContext } from '../contexts';

const App = forwardRef(({width, height}, ref) => {
	return (   
		<div
			className="app"
			ref={ref}
		>
			<AppContext.Provider 
				value={{
					elementRef: ref
				}}
			>
				<SantaContainer
					x={(width / 2) - (50 / 2)}
					y={height - 80}
					initialVelocityX={10}
					initialVelocityY={10}
				/>
			</AppContext.Provider>
		</div>      
	);
});

SantaContainerコンポーネントをAppContext.Providerで囲むことで、AppContext.Providerのvalueに指定した値がSantaContainerコンポーネント以下のコンポーネントで使えるようになります。

Santaの座標はuseElementSizeで取得した.appのサイズを基に算出しています。x座標を画面中央、y座標を画面下にしています。

initialVelocityX(Y)の値を大きくすると、矢印キーを押したときの一度に進む距離が大きくなります。

2-2. 2-1のコンテナコンポーネントを作成する

先ほどのSantaContainerと同じ要領でAppコンポーネントのコンテナコンポーネントを作成します。

containers/AppContainer.js

const AppContainer = () => {
	const elementRef = useRef(null);
	const [width, height] = useElementSize(elementRef);

	return (
		<App
			width={width}
			height={height}
			ref={elementRef}
		/>
	);
}

AppコンポーネントのwidthプロパティとheightプロパティにuseElementSizeで取得したAppコンポーネントが返すDOM要素のサイズをそれぞれセットしています。

レンダリングする

最後にReactDOM.renderを呼び出してAppContainerコンポーネント内のDOM要素をルートDOMノードである#root内にレンダリングします。また、このファイル内でスタイルシートを読み込みます。

index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { AppContainer } from './containers/App';
import './index.css';

ReactDOM.render(<AppContainer/>, document.getElementById('root'));

これでSantaコンポーネントのDOM要素が矢印キーの操作で左右に動いたりジャンプしたりするようになりました。

コード全体

完成したコードはそれぞれ以下の通りです。

HTML

<div id="root"></div>

CSS

html,
body {
	height: 100%;
	width: 100%;
}
body {
	display: flex;
	align-items: center;
	justify-content: center;
	margin: 0;
	overflow: hidden;
}
.app {
	background-color: midnightblue;
	height: 320px;
	position: relative;
	width: 480px;
}

.santa {
	background-color: red;
	height: 80px;
  position: absolute;
	width: 40px;
}

JavaScript

const { useState, useEffect, useCallback, useRef, useContext, memo, forwardRef, createContext } = React;

const AppContext = createContext();

const useElementSize = (elementRef) => {
  const [height, setHeight] = useState(0);
  const [width, setWidth] = useState(0);
  
  useEffect(() => {
    const rect = elementRef.current.getBoundingClientRect();
    setHeight(rect.height);
    setWidth(rect.width);
  }, []);
  
  return [width, height];
}

const useAnimationFrameCount = () => {
	const [frameCount, setFrameCount] = useState(0);

	useEffect(() => {
		const loop = () => {
			setFrameCount(prevFrameCount => prevFrameCount + 1);
			requestAnimationFrame(loop);
		};

		requestAnimationFrame(loop);
	}, []);

	return frameCount;
}

const usePressKeyStatus = () => {
	const [stateOfPressKey, setStateOfPressKey] = useState({});
	const handleKeyUp = useCallback((e) => {
		const keyCode = e.keyCode;

		if (keyCode === 37) {// left
			setStateOfPressKey(state => ({
				...state,
				left: false
			}));
		} 
		if (keyCode === 39) {//right
			setStateOfPressKey(state => ({
				...state,
				right: false
			}));
		} 
		if (keyCode === 38) {//up
			setStateOfPressKey(state => ({
				...state,
				top: false
			}));
		}
	}, []);

	const handleKeyDown = useCallback((e) => {
		const keyCode = e.keyCode;

		if (keyCode === 37) {// left
			setStateOfPressKey(state => ({
				...state,
				left: true
			}));
		} 
		if (keyCode === 39) {//right
			setStateOfPressKey(state => ({
				...state,
				right: true
			}));
		} 
		if (keyCode === 38) {//up
			setStateOfPressKey(state => ({
				...state,
				top: true
			}));
		}
	}, []);

	useEffect(() => {
		addEventListener('keydown', e => handleKeyDown(e));
		addEventListener('keyup', e => handleKeyUp(e));
	}, []);

	return stateOfPressKey;
}

const useDistanceXByPressKey = (elementRef, initialVelocityX) => {
	const [positionX, setPositionX] = useState(0);
	const stateOfPressKey = usePressKeyStatus();
	const appInfo = useContext(AppContext);
	const frameCount = useAnimationFrameCount();

	useEffect(() => {
		if(!elementRef.current) return;

		const rect = elementRef.current.getBoundingClientRect();
		const appRect = appInfo.elementRef.current.getBoundingClientRect();
		
		let velocityX = 0;

		if (stateOfPressKey.left) {
			if (rect.left - appRect.left <= 0) {
				velocityX = 0;
			} else {
				velocityX -= initialVelocityX;
			}
		}

		if (stateOfPressKey.right) {
			if (rect.right - appRect.right >= 0) {
				velocityX = 0;
			} else {
				velocityX += initialVelocityX;
			}
		}

		setPositionX(prevPositionX => prevPositionX + velocityX);
	}, [frameCount]);

	return positionX;
}

const useJumpHeightByPressKey = (initialVelocityY, gravity) => {// initialVelocity初速度
	const [positionY, setPositionY] = useState(0);
	const isJumping = useRef(false);
	const elapsedTime = useRef(0);
	const stateOfPressKey = usePressKeyStatus();
	const frameCount = useAnimationFrameCount();

	useEffect(() => {
		if (stateOfPressKey.top) {
			if(!isJumping.current) {//着地しているときのみ
				isJumping.current = true;
				elapsedTime.current = 0;
			}
		}

		let velocityY = (0.5 * gravity * (elapsedTime.current ** 2) - initialVelocityY * elapsedTime.current);

		elapsedTime.current++;

		//positionY > 0
		if(velocityY > 0) {//着地したら
			isJumping.current = false;
		}

		if(isJumping.current) {
			setPositionY(velocityY);
		}else{
			setPositionY(0);
		}
	}, [frameCount]);

	return positionY;
}

const Santa = memo(forwardRef(({x, y, offsetX, offsetY}, ref) => {
	const style = {
		left: x,
		top: y,
		transform: `translate3d(${offsetX}px, ${offsetY}px, 0)`
	};

	return (
		<div
			className="santa"
			style={style}
			ref={ref}
		>
		</div>
	);
}));

const gravity = .3;

const SantaContainer = ({ x, y, initialVelocityX, initialVelocityY }) => {
	const elementRef = useRef(null);
	const offsetX = useDistanceXByPressKey(elementRef, initialVelocityX);
	const offsetY = useJumpHeightByPressKey(initialVelocityY, gravity);

	return (
		<Santa
          x={x}
          y={y}
          offsetX={offsetX}
          offsetY={offsetY}
          ref={elementRef}
        />
	);
};

const App = forwardRef(({width, height}, ref) => {
  return (   
    <div
      className="app"
      ref={ref}
    >
      <AppContext.Provider 
      	value={{
      		elementRef: ref
      	}}
      >
        <SantaContainer
        	x={(width / 2) - (50 / 2)}
			y={height - 80}
          	initialVelocityX={10}
          	initialVelocityY={10}
        />
      </AppContext.Provider>
    </div>      
  );
});

const AppContainer = () => {
	const elementRef = useRef(null);
  const [width, height] = useElementSize(elementRef);
  
  return (
  	<App
      width={width}
      height={height}
      ref={elementRef}
    />
  );
}

ReactDOM.render(<AppContainer/>, document.getElementById('root'));

参考文献