これを作るよ!!
React 製のドラッグアンドドロップによる並べ替え可能なリストの作成を行います。
React対応のドラッグアンドドロップによる並べ替えを可能にするライブラリは
React DnD
dnd kit
react-beautiful-dnd
等がありますが、基本的に欲しいものは単純なドラッグアンドドロップによる並べ替え機能だけなので、今回はこれらのライブラリ無しで行います。
せっかくなので、プレーンなReactプロジェクトを作ってその上に構築していきます。
React 構築
nodeがすでにインストールされている前提で行います。以下のコマンドを実行。
create-react-app の実行
npx create-react-app drag_and_drop_sample --template typescript
ディレクトリ移動
cd drag_and_drop_sample
起動コマンド実行
npm start
react アプリが立ち上がりました。
コンポーネントの追加、余分箇所の除去
src 配下に以下のファイルを作成
type DraggableBoxProps = {
title: string;
id: number;
onDragStart: React.DragEventHandler;
onDragEnter: React.DragEventHandler;
onDragEnd: () => void;
}
const DraggableBox = (props: DraggableBoxProps) => {
const {title, id, onDragStart, onDragEnter,onDragEnd} = props;
return (
<div
style={{
width: "200px",
height: "100px",
border: "1px solid black"
}}
onDragStart={onDragStart}
onDragEnter={onDragEnter}
onDragEnd={onDragEnd}
draggable={true}
>
<p>{title}</p>
<p>{id}</p>
</div>
);
};
export default DraggableBox;
draggable={true}
をつけると要素をドラッグアンドドロップ可能な要素として設定ができます。
HTMLElement: draggable プロパティ - Web API | MDN
onDragStart, onDragEnter, onDragEnd は HTML ドラッグ & ドロップ API イベントのためのイベントハンドラタイプです。
<div> などの一般的なコンポーネント – React
### DragEvent
ハンドラ関数
ページファイルを以下に修正(初期要素除去、子コンポーネントのimport、データ、関数の雛形作成)
-import React from 'react';
-import logo from './logo.svg';
+import DraggableBox from './DraggableBox';
+import { useRef, useState } from 'react';
import './App.css';
function App() {
+ const dragItem = useRef<number | null>(null);
+ const dragOverItem = useRef<number | null>(null);
+ const [data,setData] = useState([
+ {
+ id: 1,
+ title: "apple",
+ },
+ {
+ id: 2,
+ title: "banana",
+ },
+ {
+ id: 3,
+ title: "orange",
+ },
+ {
+ id: 4,
+ title: "grape",
+ },
+ {
+ id: 5,
+ title: "kiwi",
+ }
+]);
+
+const drop = () => {
+}
+
+const dragStart = (index:number) => {
+}
+
+const dragEnter = (index:number) => {
+}
return (
<div className="App">
- <header className="App-header">
- <img src={logo} className="App-logo" alt="logo" />
- <p>
- Edit <code>src/App.tsx</code> and save to reload.
- </p>
- <a
- className="App-link"
- href="https://reactjs.org"
- target="_blank"
- rel="noopener noreferrer"
- >
- Learn React
- </a>
- </header>
+ {data.map((datum, index) => (
+ <DraggableBox title={datum.title} id ={datum.id} key = {index}
+ onDragStart={() => dragStart(index)}
+ onDragEnter={() => dragEnter(index)}
+ onDragEnd={drop}
+ />
+ ))}
</div>
);
}
export default App;
現時点で以下のようになっています。
関数の導入
関数を修正します。
以下のようなドラッグアンドドロップで並び替える機構が完成します。
完成したコードは以下、
import DraggableBox from './DraggableBox';
import { useRef, useState } from 'react';
import './App.css';
function App() {
const dragItem = useRef<number | null>(null);
const dragOverItem = useRef<number | null>(null);
const [data,setData] = useState([
{
id: 1,
title: "apple",
},
{
id: 2,
title: "banana",
},
{
id: 3,
title: "orange",
},
{
id: 4,
title: "grape",
},
{
id: 5,
title: "kiwi",
}
]);
const drop = () => {
+ const copyListItems = [...data];
+ if (dragItem.current !== null && dragOverItem.current !== null) {
+ const dragItemContent = copyListItems[dragItem.current];
+ copyListItems.splice(dragItem.current, 1);
+ copyListItems.splice(dragOverItem.current, 0, dragItemContent);
+ }
+ dragItem.current = null;
+ dragOverItem.current = null;
+ setData(copyListItems)
}
const dragStart = (index:number) => {
+ dragItem.current = index;
}
const dragEnter = (index:number) => {
+ dragOverItem.current = index;
}
return (
<div className="App">
{data.map((datum, index) => (
<DraggableBox title={datum.title} id ={datum.id} key = {index}
onDragStart={() => dragStart(index)}
onDragEnter={() => dragEnter(index)}
onDragEnd={drop}
/>
))}
</div>
);
}
export default App;
コメントを全行につけるとこんなところでしょうか?
import DraggableBox from './DraggableBox';
import { useRef, useState } from 'react';
import './App.css';
function App() {
// ドラッグ開始対象要素の参照オブジェクト
const dragItem = useRef<number | null>(null);
// ドラッグが重なっている対象要素の参照オブジェクト
const dragOverItem = useRef<number | null>(null);
// ドラッグ対象のデータ
const [data,setData] = useState([
{
id: 1,
title: "apple",
},
{
id: 2,
title: "banana",
},
{
id: 3,
title: "orange",
},
{
id: 4,
title: "grape",
},
{
id: 5,
title: "kiwi",
}
]);
// ドラッグ終了時の処理
const drop = () => {
// ドラッグ対象のデータをコピー
const copyListItems = [...data];
// ドラッグ対象のデータがnullでない場合
if (dragItem.current !== null && dragOverItem.current !== null) {
// ドラッグ対象のデータを取得
const dragItemContent = copyListItems[dragItem.current];
// ドラッグ対象のデータを削除
copyListItems.splice(dragItem.current, 1);
// ドラッグ対象のデータをドラッグが重なっている対象の位置に挿入
copyListItems.splice(dragOverItem.current, 0, dragItemContent);
}
// ドラッグ開始対象要素の参照オブジェクトをnullにする
dragItem.current = null;
// ドラッグが重なっている対象要素の参照オブジェクトをnullにする
dragOverItem.current = null;
// ドラッグ対象のデータを更新
setData(copyListItems)
}
// ドラッグ対象のデータのインデックスを取得
const dragStart = (index:number) => {
dragItem.current = index;
}
// ドラッグが重なっている対象のデータのインデックスを取得
const dragEnter = (index:number) => {
dragOverItem.current = index;
}
return (
<div className="App">
{data.map((datum, index) => (
<DraggableBox title={datum.title} id ={datum.id} key = {index}
onDragStart={() => dragStart(index)}
onDragEnter={() => dragEnter(index)}
onDragEnd={drop}
/>
))}
</div>
);
}
export default App;
コメントをつけてもわかりにくいですね。
コード理解のポイント
ポイント1 関数の発火のタイミング
drop
,
dragStart
,
dragEnter
,
のそれぞれ関数の発火のタイミングを見てみましょう。
関数にconsole.logを仕込み、動作させてみます。
// イベントハンドラの登録箇所
// onDragStart, onDragEnter, onDragEnd
<DraggableUi title={datum.title} id ={datum.id} key = {index}
onDragStart={() => dragStart(index)}
onDragEnter={() => dragEnter(index)}
onDragEnd={drop}
/>
// それぞれconsole.log 記述箇所、抜粋
// ドラッグ終了時の処理
const drop = () => {
console.log("drop発火");
// ドラッグ対象のデータのインデックスを取得
const dragStart = (index:number) => {
console.log("dragStart発火");
// ドラッグが重なっている対象のデータのインデックスを取得
const dragEnter = (index:number) => {
console.log("dragEnter発火");
タイミング的には以下で発火していることがわかります。
onDragStart
- 発火タイミング: 要素のドラッグが開始された瞬間
- 何をしている?: dragStartの発火 → 果物のアイテム要素のインデックスを dragItem に保持
onDragEnter
- 発火タイミング: ドラッグ中の要素が、別の要素の領域に入った時
- 何をしている?: dragEnterの発火 → ドラッグが重なっている果物要素のインデックスを dragOverItem に保持
onDragEnd
-
発火タイミング: ドラッグ操作が完了した時
-
何をしている?: drop 処理の発火 → (処理詳細は後述)
補足として、より完全なドラッグ&ドロップの実装には以下のイベントも有用です:
-
onDragOver
: ドラッグ中の要素が別の要素の上にある間、継続的に発火 -
onDragLeave
: ドラッグ中の要素が別の要素の領域から出た時に発火 -
onDrop
: 要素がドロップされた時に発火
ポイント2 ドラッグ操作完了時は何をしている?
dragStart
,
dragEnter
,
はなんだか大した事なさそうです。
drop
関数を追いかけて見ましょう。
drop
2 番目の果物を 4番目に引っ張ってきたときの処理イメージを上から順に見てみましょう。
// ドラッグ終了時の処理
const drop = () => {
// ドラッグ対象のデータをコピー
const copyListItems = [...data];
特に難しいことはありません。
コピーしているだけです
// ドラッグ対象のデータがnullでない場合
if (dragItem.current !== null && dragOverItem.current !== null) {
// ドラッグ対象のデータを取得
const dragItemContent = copyListItems[dragItem.current];
// ドラッグ対象のデータを削除
copyListItems.splice(dragItem.current, 1);
if 文は単なるnullチェックで、
dragItemContent に 2 番目の果物(ドラッグ対象のデータ) を添え字のdragItem.current
インデックスから参照し保持します。
次に、copyListItem から 2 番目の果物(ドラッグ対象のデータ)を削除しています。
※こちらもdragItem.current インデックスから位置を参照しています。
// ドラッグ対象のデータをドラッグが重なっている対象の位置に挿入
copyListItems.splice(dragOverItem.current, 0, dragItemContent);
copyListItems から、ドラッグが重なっている対象の位置を割り出し(dragOverItem.current のindex位置)
dragItemContent に保持していた 2 番目の果物(ドラッグ対象のデータ) を置き換えます。
// ドラッグ開始対象要素の参照オブジェクトをnullにする
dragItem.current = null;
// ドラッグが重なっている対象要素の参照オブジェクトをnullにする
dragOverItem.current = null;
// ドラッグ対象のデータを更新
setData(copyListItems)
dragItem.current = null;
dragOverItem.current = null;
部分で参照オブジェクトをそれぞれリセットし
setData(copyListItems)
で copyListItem から 画面の描画リストの元となる data に 入れ直し
画面が再レンダリングされます。
data の通りに並び替えが行われます。
例 MUI を導入した例
MUI: The React component library you always wanted
UIフレームワークのMUI で 要素を作ってみることも可能です。
以下のコマンドを実行しMUI をインストール
npm install @mui/material @emotion/react @emotion/styled
DraggableBox を以下に変更
import Stack from '@mui/material/Stack';
type DraggableUiProps = {
title: string;
id: number;
onDragStart: React.DragEventHandler;
onDragEnter: React.DragEventHandler;
onDragEnd: () => void;
}
const DraggableBox = (props: DraggableUiProps) => {
const {title, id, onDragStart, onDragEnter,onDragEnd} = props;
return (
<Stack
spacing={2}
sx={{
width:'200px',
height:'100px',
border: '1px solid black'
}}
onDragStart={onDragStart}
onDragEnter={onDragEnter}
onDragEnd={onDragEnd}
draggable={true}
>
<h1>{title}</h1>
<h2>{id}</h2>
</Stack>
);
};
export default DraggableBox;
まとめ
作ってみると stateとprops を更新することによって画面の描画を行う、実にReactらしい機構になりました。
ライブラリを使っていない分、若干複雑になりましたが、
以前ライブラリをつかって実装してみたこともあり、ある程度ライブラリが隠蔽してくれる部分はあるのですが
例えば以下の発火タイミング
- onDragStart:要素のドラッグが開始された瞬間
- onDragEnter:ドラッグ中の要素が、別の要素の領域に入った時
- onDragEnd:ドラッグ操作が完了した時
- onDragOver:ドラッグ中の要素が別の要素の上にある間、継続的に発火
- onDragLeave:ドラッグ中の要素が別の要素の領域から出た時に発火
- onDrop:要素がドロップされた時に発火
これらを意識した実装にせざる負えないので「ライブラリを使っても普通に難しかった」というのが感想です。
必要最低限の機能でいいのであれば、ライブラリ無しで作ってしまうのも候補として有りだと思います。