サイトトップ

Director Flash 書籍 業務内容 プロフィール

HTML5テクニカルノート

Create React App + React DnD: 単純なドラッグ&ドロップ


Reactアプリケーションにドラッグ&ドロップを実装するReact DnDの「Examples」サイトには、作例がいくつかに分類されて掲載されています。本項では、その中でも基本的な「Drag Around」にある「Naive」のつくり方と構文を解説しましょう。作例のひな形は、Create React Appでつくってください(ひな形アプリケーションのつくり方については、「Create React App 入門 01: 3×3マスのゲーム盤をつくる」01「Reactアプリケーションのひな形をつくる」参照)。

01 ドラッグ対象とドロップ先領域の要素を静的にレイアウトする

まず、インタラクションはあと回しにして、ドラッグ対象の要素とドロップ先領域の静的なレイアウトです(図001)。ドラッグする要素は波線で縁取りしてふたつ置き、ドロップ先領域には実線の枠を加えることにしました。

図001■ドラッグ対象の要素ふたつとドロップ先領域の静的なレイアウト

図001

ドラッグする要素のモジュールsrc/Box.jsxが、つぎのコード001です。コンポーネントのプロパティには、位置決めの左(left)と上(top)の座標値、および差し込むテキストをchildrenで受け取っています。

コード001■ドラッグする要素のモジュール

src/Box.jsx

const style = {
	position: 'absolute',
	border: '1px dashed gray',
	backgroundColor: 'white',
	padding: '0.5rem 1rem',
	cursor: 'move',
	width: 'max-content',
};
const role = 'Box';
export const Box = ({ left, top, children }) => {
	return (
		<div style={{ ...style, left, top }} role={role}>
			{children}
		</div>
	);
};

つぎのコード002が、ドラッグ先の領域となるモジュールsrc/Container.jsxの記述です。ドラッグするふたつの要素のデータは、このコンポーネントの状態(boxes)としてもたせました。それらを、前掲コンポーネント(Box)のプロパティとして渡します。

コード002■ドラッグ先の領域となるモジュール

src/Container.jsx

import { useState } from 'react';
import { Box } from './Box';

const styles = {
	width: 300,
	height: 300,
	border: '1px solid black',
	position: 'relative',
};
export const Container = () => {
	const [boxes, setBoxes] = useState({
		a: { top: 20, left: 80, title: 'Drag me around' },
		b: { top: 180, left: 20, title: 'Drag me too' },
	});
	return (
		<div style={styles}>
			{Object.keys(boxes).map((key) => {
				const { left, top, title } = boxes[key];
				return (
					<Box key={key} left={left} top={top}>
						{title}
					</Box>
				);
			})}
		</div>
	);
};

ドラッグ領域のコンポーネント(Container)は今の段階なら、後掲ルートモジュール(src/App.js)にすぐに組み込んでしまってもよさそうです。ただ、あとでもう少し手を加えるため、取りまとめのモジュール(src/Example.jsx)を間にひとつ加えておきます。今はただ、ドラッグ領域のコンポーネントを包んでいるだけです。

コード003■取りまとめのモジュール

src/Example.jsx

import { Container } from './Container';

export const Example = () => {
	return (
		<div>
			<Container />
		</div>
	);
};

ルートモジュールsrc/App.jsの記述はつぎのコード004のとおりです。ひな形アプリケーションのsrc/index.jsがこのコンポーネント(App)を読み込んで、ドラッグする要素とドラッグ先の領域がページに描かれます(前出図001)。

コード004■ルートモジュール

src/App.js

import { Example } from './Example';

function App() {
	return (
		<div>
			<Example />
		</div>
	);
}

export default App;

02 要素をドラッグする

つぎに、要素がドラッグできるようにします。React DnDに加えてバックエンドのreact-dnd-html5-backendもインストールしてください(「Create React App + React DnD 02: ドラッグ&ドロップで動かす」01「コンポーネントをドラッグする」参照)。


npm install react-dnd react-dnd-html5-backend 

アプリケーションにReact DnDの機能を与えるのが、DndProviderコンポーネントです。バックエンド(HTML5Backend)は、backendプロパティに定めてください。

src/App.js

import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';

function App() {
	return (
		<div>
			<DndProvider backend={HTML5Backend}>
				<Example />
			</DndProvider>
		</div>
	);
}

ルートモジュールsrc/App.jsには、もうこれ以上手は加えません。記述全体をつぎのコード005にまとめて掲げましょう。

コード005■ルートモジュール

src/App.js

import { Example } from './Example';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';

function App() {
	return (
		<div>
			<DndProvider backend={HTML5Backend}>
				<Example />
			</DndProvider>
		</div>
	);
}

export default App;

ドロップ領域のモジュールsrc/Container.jsxには、ドラッグするコンポーネント(Box)に渡すプロパティ(id)をひとつ加えます。あとでドロップ操作が行われたとき、どの要素の位置を変えるのか識別するための値です。

src/Container.jsx

export const Container = () => {

	return (
		<div style={styles}>
			{Object.keys(boxes).map((key) => {
				const { left, top, title } = boxes[key];
				return (
					// <Box key={key} left={left} top={top}>
					<Box key={key} id={key} left={left} top={top}>
						{title}
					</Box>
				);
			})}
		</div>
	);
};

ドラッグする要素のコンポーネント(Box)は、新しく加えられた識別のためのプロパティ(id)を受け取ります。そして、ドラッグできるようにするために用いるReact DnDのフックが、useDragです。引数の関数は、ドラッグする項目の仕様をオブジェクトで返します。オブジェクトに必須のプロパティがtypeitemです。typeの値でドロップ先を識別します。また、ドロップしたあと、項目の識別や処理に用いられる情報を収めるのがitemオブジェクトです。typeの値は、以下のコード006のとおり、別モジュールsrc/ItemTypes.jsで定数に定めました。フックの戻り値は配列で、第2要素(drag)をドラッグするJSX要素のrefプロパティに与えます。

src/Box.jsx

import { useDrag } from 'react-dnd';
import { ItemTypes } from './ItemTypes';

// export const Box = ({ left, top, children }) => {
export const Box = ({ id, left, top, children }) => {
	const [, drag] = useDrag(() => ({
		type: ItemTypes.BOX,
		item: { id, left, top },
	}), [id, left, top]);
	return (
		// <div style={{ ...style, left, top }} role={role}>
		<div ref={drag} style={{ ...style, left, top }} role={role}>
			{children}
		</div>
	);
};

コード006■定数を定めたモジュール

src/ItemTypes.js

export const ItemTypes = {
	BOX: 'box',
};

これでふたつの要素はドラッグできます(図002)。けれど、ドロップの処理が、まだ加えられていません。ドロップできなかった要素は、もとの位置に戻ってしまいます。

図002■ふたつの要素がドラッグできる

図001

03 要素をドロップする

ドロップの処理に移る前に、モジュールをひとつインストールします。もとのオブジェクトはそのままに、プロパティが書き替えられた新たなオブジェクトをつくるimmutability-helperです。


npm install immutability-helper --save

関数update()の第1引数にもとオブジェクト、第2引数に変えたいプロパティが収められたオブジェクト、そしてその変更のし方をコマンドで定めます。今回用いる{$merge: object}は、一部のプロパティを書き替えたり、追加するコマンドです。


const boxes = {
	a: { top: 20, left: 80, title: 'Drag me around' },
	b: { top: 180, left: 20, title: 'Drag me too' },
};
const newBoxes = update(boxes, {
	['a']: {
		$merge: { left: 0, top: 0 },
	},
});
console.log(boxes.a);  // {top: 20, left: 80, title: "Drag me around"}
console.log(newBoxes.a);  // {top: 0, left: 0, title: "Drag me around"}

ドロップ先のコンポーネントに用いるのは、useDropフックです。引数の関数から返す仕様オブジェクトのプロパティacceptには、ドロップを許す要素(ソースオブジェクト)のtypeを定めます。そして、drop()メソッドは、受け入れられるソースがドロップされたときに呼び出されるコールバックです。フックが返す配列の第2要素が、ドロップ先コンポーネントにJSX要素のrefプロパティとして与える参照になります。

コールバックdrop()の処理をみましょう。ふたつの引数は、ドラッグされている項目オブジェクト(item)とドラッグを監視するオブジェクト(monitor)です。監視オブジェクトに対してgetInitialSourceClientOffset()を呼び出すと、ドラッグし始めたポインタ位置標からの差分の座標({ x, y })が得られます。したがって、ドロップされたときその座標をもとの要素の位置に足し込めば、ドロップしたところに移動できるのです。そして、状態設定関数(setBoxes())に新たなデータを渡すとき、immutability-helperupdate()関数を用いました。

src/Container.jsx

// import { useState } from 'react';
import { useCallback, useState } from 'react';
import { useDrop } from 'react-dnd';
import update from 'immutability-helper';
import { ItemTypes } from './ItemTypes';


export const Container = () => {

	const moveBox = useCallback((id, left, top) => {
		setBoxes(update(boxes, {
			[id]: {
				$merge: { left, top },
			},
		}));
	}, [boxes, setBoxes]);
	const [, drop] = useDrop(() => ({
		accept: ItemTypes.BOX,
		drop(item, monitor) {
			const { x, y } = monitor.getDifferenceFromInitialOffset();
			const left = Math.round(item.left + x);
			const top = Math.round(item.top + y);
			moveBox(item.id, left, top);
			return undefined;
		},
	}), [moveBox]);
	return (
		// <div style={styles}>
		<div ref={drop} style={styles}>

		</div>
	);
};

04 ドラッグ中はもとの要素を隠す

React DnDは、ドラッグする要素から別のオブジェクトをつくってドラッグアニメーションさせます。デフォルトでは、もと要素はそのまま動かずに表示されているということです。これを、ドラッグ中は非表示にできるようにしましょう。まず、その切り替えのチェックボックスをモジュールsrc/Example.jsxにつぎのように加えます。非表示にするかしないかのチェックボックスの値は状態変数(hideSourceOnDrag)にもたせて、ドロップ領域の子コンポーネント(Container)のプロパティとして渡しました。

src/Example.jsx

import { useState, useCallback } from 'react';

export const Example = () => {
	const [hideSourceOnDrag, setHideSourceOnDrag] = useState(true);
	const toggle = useCallback(() => setHideSourceOnDrag(!hideSourceOnDrag), [
		hideSourceOnDrag,
	]);
	return (
		<div>
			{/* <Container /> */}
			<Container hideSourceOnDrag={hideSourceOnDrag} />
			<p>
				<label htmlFor="hideSourceOnDrag">
					<input id="hideSourceOnDrag" type="checkbox" checked={hideSourceOnDrag} onChange={toggle}/>
					<small>Hide the source item while dragging</small>
				</label>
			</p>
		</div>
	);
};

ドロップ領域のモジュールsrc/Container.jsxは、親から受け取ったドラッグ中の表示/非表示のプロパティ(hideSourceOnDrag)をドラッグする要素のコンポーネント(Box)にプロパティでバケツリレーします。

src/Container.jsx

// export const Container = () => {
export const Container = ({ hideSourceOnDrag }) => {

	return (
		<div ref={drop} style={styles}>
			{Object.keys(boxes).map((key) => {

				return (
					// <Box key={key} id={key} left={left} top={top}>
					<Box key={key} id={key} left={left} top={top} hideSourceOnDrag={hideSourceOnDrag}>

					</Box>
				);
			})}
		</div>
	);
};

こうして、ドラッグする要素のモジュール(src/Box.jsx)は、チェックボックスのオン/オフ(hideSourceOnDrag)にもとづいて、もとの要素の非表示と表示を切り替えればよいのです。非表示にするときは、スタイルの適用されない空の要素(<div>)にして隠すことにしました。ドラッグ中であるかどうかを確かめるのが、監視オブジェクトに対するisDragging()メソッドです。この戻り値をcollect()メソッドでコンポーネントに注入(useDragフックから返される配列の第1要素)して非表示の判定に用いています。

src/Box.jsx

// export const Box = ({ id, left, top, children }) => {
export const Box = ({ id, left, top, hideSourceOnDrag, children, }) => {
	// const [, drag] = useDrag(() => ({
	const [{ isDragging }, drag] = useDrag(() => ({

		collect: (monitor) => ({
			isDragging: monitor.isDragging(),
		}),
	}), [id, left, top]);
	if (isDragging && hideSourceOnDrag) {
		return <div ref={drag}/>;
	}
	return (

	);
};

これで、チェックボックスがチェックされていると、ドラッグ中はもと要素が表示されなくなりました(図003)。書き替えたモジュールの記述全体は、以下のコード007のとおりです。CodeSandboxにサンプル001を公開しましたので、具体的な動きやモジュールごとのコードはこちらでお確かめください。

図003■ドラッグ中はもと要素を隠すチェックボックスが加わった

図001

コード007■ドラッグ中にもと要素を隠すチェックボックスが加わった

src/Example.jsx

import { useState, useCallback } from 'react';
import { Container } from './Container';

export const Example = () => {
	const [hideSourceOnDrag, setHideSourceOnDrag] = useState(true);
	const toggle = useCallback(() => setHideSourceOnDrag(!hideSourceOnDrag), [
		hideSourceOnDrag,
	]);
	return (
		<div>
			<Container hideSourceOnDrag={hideSourceOnDrag} />
			<p>
				<label htmlFor="hideSourceOnDrag">
					<input id="hideSourceOnDrag" type="checkbox" checked={hideSourceOnDrag} onChange={toggle}/>
					<small>Hide the source item while dragging</small>
				</label>
			</p>
		</div>
	);
};

src/Container.jsx

import { useCallback, useState } from 'react';
import { useDrop } from 'react-dnd';
import { ItemTypes } from './ItemTypes';
import { Box } from './Box';
import update from 'immutability-helper';

const styles = {
	width: 300,
	height: 300,
	border: '1px solid black',
	position: 'relative',
};
export const Container = ({ hideSourceOnDrag }) => {
	const [boxes, setBoxes] = useState({
		a: { top: 20, left: 80, title: 'Drag me around' },
		b: { top: 180, left: 20, title: 'Drag me too' },
	});
	const moveBox = useCallback((id, left, top) => {
		setBoxes(update(boxes, {
			[id]: {
				$merge: { left, top },
			},
		}));
	}, [boxes, setBoxes]);
	const [, drop] = useDrop(() => ({
		accept: ItemTypes.BOX,
		drop(item, monitor) {
			const { x, y } = monitor.getDifferenceFromInitialOffset();
			const left = Math.round(item.left + x);
			const top = Math.round(item.top + y);
			moveBox(item.id, left, top);
			return undefined;
		},
	}), [moveBox]);
	return (
		<div ref={drop} style={styles}>
			{Object.keys(boxes).map((key) => {
				const { left, top, title } = boxes[key];
				return (
					<Box key={key} id={key} left={left} top={top} hideSourceOnDrag={hideSourceOnDrag}>
						{title}
					</Box>
				);
			})}
		</div>
	);
};

src/Box.jsx

import { useDrag } from 'react-dnd';
import { ItemTypes } from './ItemTypes';

const style = {
	position: 'absolute',
	border: '1px dashed gray',
	backgroundColor: 'white',
	padding: '0.5rem 1rem',
	cursor: 'move',
	width: 'max-content',
};
const role = 'Box';
export const Box = ({ id, left, top, hideSourceOnDrag, children, }) => {
	const [{ isDragging }, drag] = useDrag(() => ({
		type: ItemTypes.BOX,
		item: { id, left, top },
		collect: (monitor) => ({
			isDragging: monitor.isDragging(),
		}),
	}), [id, left, top]);
	if (isDragging && hideSourceOnDrag) {
		return <div ref={drag}/>;
	}
	return (
		<div ref={drag} style={{ ...style, left, top }} role={role}>
			{children}
		</div>
	);
};

サンプル001■React DnD: Drag around naive


作成者: 野中文雄
作成日: 2021年06月12日


Copyright © 2001-2020 Fumio Nonaka.  All rights reserved.