始めに
STORES 予約でエンジニアをしているTak-Iwamoto です。
今回はある項目の並び替え機能を実装する際に React DnD を使用したので、その知見について書かせていただきます。 実装した画面はこんな感じです。
ライブラリ
STORES 予約の管理画面は Rails の slim -> React へのリプレイスが進行中です。
なので React のドラッグ&ドロップライブラリを検討しました。
star 数の多いライブラリは以下のものがあります。
React-Draggable は最終リリース ver が 2016/12 なので対象外として、React DnD と react-beautfiul-dnd のどちらかが候補となります。
あくまで自分の考えではありますが、それぞれの pros, cons を列挙してみました。
React DnD
pros
- 継続的にメンテされている(2021/03/05 時点で最新 ver は 2021/02/24 にリリース)
- TypeScript で実装されているので、Definitely Typedが必要ない
- Hooks API がある
cons
- 学習コストが高め(な気がする)
- 提供される機能が react-beautiful-dnd と比較すると少ない
API が洗練されているが、少し学習コストが高い印象です。
react-beautiful-dnd
pros
cons
- React DnD と比較すると活発にメンテされていない(2021/03/05 時点で最新 ver は約 1 年前のリリース)
- Definitely Typed が必要
- React DnD が html 5 のドラッグ&ドロップ機能を使って実装されているが、react-beautiful-dnd はそうではない
(react-beautiful-dnd の README では皮肉たっぷりな感じで html5 の drag and drop を dis っているが...)
There are a lot of libraries out there that allow for drag and drop interactions within React. Most notable of these is the amazing react-dnd. It does an incredible job at providing a great set of drag and drop primitives which work especially well with the wildly inconsistent html5 drag and drop feature.
大手企業のAtlassianが作っているので doc は充実しているが、最近メンテされていないのが気になるといった感じです。
今回は以上のことを踏まえて React DnD を採用しました。複雑な機能が必要でなかったことや継続的にメンテされている安心感が決め手です。
React DnD 解説
前提
前提として React DnD はドラッグ&ドロップの見た目に関する機能は一切提供していません。
(react-beautiful-dnd は垂直方向か並行方向か選べたりなど、見た目に関する API もある)
React DnD はview
ではなく data
を管理します。
いわば、ドラッグ&ドロップにまつわる状態管理のライブラリが React DnD です。
ドラッグ&ドロップの状態管理を行う上で React DnD で使用されている概念について説明していきます。
Item
React DnD はドラッグやドロップの動作が発生したとき、DOM やコンポーネントではなく、JavaScript の Object で表現された Item が移動していると認識します。
この Item はtype
というプロパティが必須です。type
は一意の文字列で、これによりドラッグされるコンポーネントとそのドロップ先のコンポーネントを一意に紐づけています。
Redux に馴染みのある方は Redux の Action Type に相当する物が Item の type と考えてもらって問題ありません。
Monitor
ドラッグ&ドロップ中の状態をモニターするためのオブジェクトです。
Collect
Monitor を引数に取ってドラッグ&ドロップ中の状態を collect 関数で取り出します。
例えば、以下の例ではドラッグ中のコンポーネントがドロップ可能な時に highlightedにするためのオブジェクトを返しています。
function collect(monitor) { return ( highlighted: monitor.canDrop(), ) }
一通り React DnD の概念的な部分を説明したので、次はドラッグとドロップに関する API を見ていきます。
API
useDrag
ドラッグするコンポーネントでこの hooks を使います。
export const Draggable: VFC<Props> = () => { const [collected, drag, dragPreview] = useDrag({ item: { type: 'Item', }, collect: (monitor) => { return { canDrag: monitor.canDrag, highlighted: monitor.isDragging }; }, }); return <div ref={drag}>...</div>; };
戻り値
配列を返します。それぞれの要素は以下の通りです。
- index 0(例の collected): collect 関数で返されたオブジェクト。 つまり上の例では以下のオブジェクトになる。
const collected = { canDrag: monitor.canDrag, highlighted: monitor.isDragging };
- index 1(例の drag): ドラッグ対象の ref に渡す。
- index 2(例の dragPreview): ドラッグ中の preview で表示する DOM の ref に渡す。
引数
item
の type
が必須でそれ以外は optional です。
先ほどの React DnD の概念でも説明しましたが、この type
は全てのコンポーネントのなかで一意の文字列である必要があります。
ドラッグ&ドロップが大量に発生するのであれば、Redux の Action Type のように列挙しておくと便利です。
export const DnDItems = { Item: 'Item', } as const; export type DnDItems = typeof DnDItems[keyof typeof DnDItems];
他にも API があるので、doc を確認してみてください。
useDrop
ドロップするコンポーネントでこのhooksを使います。
export const DropTarget: VFC<Props> = () => { const [collected, drop] = useDrop({ accept: 'Item', }); return <div ref={drop}>...</div>; };
戻り値
- index 0(例の collected): useDrag と同様に collect 関数で返された Object。
- index 1(例の drop): ドロップ対象の ref に渡す。
引数
accept
が必須でそれ以外は optional です。
このaccept
に useDrag の引数のitem.type
を指定することでドラッグしたコンポーネントをドロップできます。
実践
では実際にドラッグ&ドロップを実装します。
実行環境
- Next.js 10.0.7
- React 16.12.0
- React DnD 13.1.1
- TypeScript 4.1
- tailwindcss 2.0.2
- recoil 0.1.2
まずルートのコンポーネントをDnDProvier
でラップします。
今回は状態管理にrecoil
を使用しているので、RecoilRoot
でもラップしています。
(状態管理はお好みのものを使ってください)
import { AppProps } from 'next/app'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { RecoilRoot } from 'recoil'; import 'tailwindcss/tailwind.css'; const App = ({ Component, pageProps }: AppProps) => { return ( <RecoilRoot> <DndProvider backend={HTML5Backend}> <Component {...pageProps} /> </DndProvider> </RecoilRoot> ); }; export default App;
次にitem
のtype
を列挙しておきます。今回は 1 種類だけTodo
というtype
を定義します。
export const DnDItems = { Todo: 'Todo', } as const; export type DnDItems = typeof DnDItems[keyof typeof DnDItems];
ではドラッグするコンポーネントをみていきます。
import { VFC } from 'react'; import { useDrag } from 'react-dnd'; import { useRecoilState } from 'recoil'; import { droppedColumnState } from '../recoil/dropColumn'; import { DropResult } from './DropColumn'; import { DnDItems } from '../dnd/DnDItem'; type Props = { name: string; }; export const DraggableItem: VFC<Props> = ({ name }) => { const [, setDroppedColumnNumber] = useRecoilState(droppedColumnState); const [collected, drag] = useDrag({ // 必須: itemのtypeを指定 item: { type: DnDItems.Todo, }, // ドラッグが終わったとき(ドロップしたとき)にその結果を取得する // 結果はuseDropのdrop関数で返された値をmonitor.getDropResult()で取得できる。 end: (_, monitor) => { const dropResult = monitor.getDropResult() as DropResult; if (dropResult) { // ドロップされたカラム番号をstateにセット setDroppedColumnNumber(dropResult.colNumber); } }, // 状態を取得 collect: (monitor) => { return { dragging: monitor.isDragging() }; }, }); const { dragging } = collected; // ドラッグ中の場合はopacityを変えている const opacity = dragging ? 'opacity-50' : 'opacity-100'; return ( // refにdragを渡してドラッグ対象にする <div ref={drag} className={`flex justify-center items-center rounded-2xl h-28 w-40 bg-white ${opacity}`} > <div>{name}</div> </div> ); };
説明はコメントに書いてある通りです。 end 関数の callback でドロップ結果を受け取っているのがポイントですね。
では次にドロップ先のコンポーネントをみてみます。
import React, { VFC } from 'react'; import { useDrop } from 'react-dnd'; import { useRecoilValue } from 'recoil'; import { droppedColumnState } from '../recoil/dropColumn'; import { DraggableItem } from './DraggableItem'; import { DnDItems } from '../dnd/DnDItem'; export type DropResult = { colNumber: number; }; type Props = { colNumber: number; backgroundColor: string; }; export const Column: VFC<Props> = ({ colNumber, backgroundColor }) => { const [, drop] = useDrop({ // 必須: ドラッグするコンポーネントと同じtypeを指定する accept: DnDItems.Todo, // ドロップされたときにオブジェクトを返す drop: () => ({ colNumber }), }); // ドロップされたカラム番号(useRecoilValue(droppedColumnState))とpropsのcolNumberが一致している場合はドラッグされた const isDropped = useRecoilValue(droppedColumnState) === colNumber; return ( <div // refにdropを渡してドロップ対象のコンポーネントにする。 ref={drop} className={`flex justify-center items-center h-96 w-48 ${backgroundColor}`} > {/* ドロップされた場合はドラッグしたコンポーネントを表示 */} {isDropped && <DraggableItem name='Drag Item' />} </div> ); };
最後にこれらを使って todoリスト のページを作成してみます。
import { Column } from '../components/DropColumn'; const IndexPage = () => ( <div className='grid grid-cols-3 justify-center items-center place-items-center'> <div>todo</div> <div>wip</div> <div>done</div> <div> <Column colNumber={1} backgroundColor='bg-yellow-300' /> </div> <div> <Column colNumber={2} backgroundColor='bg-red-300' /> </div> <div> <Column colNumber={3} backgroundColor='bg-blue-300' /> </div> </div> ); export default IndexPage;
完成しました。
というわけで React DnD を使ってドラッグ&ドロップを実装できました。
実際に現場で使う際には公式の exampleも参考にしてみてください。
example も TypeScript で実装されているのが嬉しいですね。
最後に
hey社はソフトウェアエンジニアはもちろん、デザイナー、PM、CS、セールス、コーポレートなど全職種大募集中です!