この記事にたどり着いたあなたはこんなことを思っていたのではないでしょうか?
「PragmaticDnDの日本語解説無くないか!!?PragmaticDnDの解説無い!!!(伝われ)」
かく言う私もこのライブラリがプロダクトに採用され、DnD実装タスクにアサインされてから絶望したものです。
日本語記事一つもねえじゃんって。公式ドキュメントも物足りないって。弱いって。厳しいって。
そんな過去の私のように困っている方の助けになればいいなと思って書いた解説記事です。
はじめに
当記事ではPragmaticDnDのExampleではじめに紹介されるListについてできるだけ詳らかに解説します。
ちょ〜〜〜っと長いですが、ぜひ最後までお付き合いください。
本記事で実装したコードはGitHubにも公開しているのでよければ参考にしてください。
使用スタックは以下の通りです
- React:
^18.3.1
-
Pragmatic drag and drop:
^1.4.0
- Tailwind CSS:
^3.4.16
Get Started
さて、こんなリストを作ってみました。
まだDragで順番を変えることのできないUndraggableなこいつをDraggableにしていきましょう。
変更前のコードはこちらです。
import { List } from "./List";
import { RiReactjsFill, RiVuejsFill, RiSvelteFill } from "react-icons/ri";
function App() {
const array = [
{
id: "react",
icon: <RiReactjsFill size={"22px"} />,
title: "React",
description: "React is developed by Jordan Walke.",
},
{
id: "vue",
icon: <RiVuejsFill size={"22px"} />,
title: "Vue",
description: "Vue is developed by Evan You.",
},
{
id: "svelte",
icon: <RiSvelteFill size={"22px"} />,
title: "Svelte",
description: "Svelte is developed by Rich Harris.",
},
];
return (
<div className="w-1/2">
<h1 className="font-bold text-4xl w-full mb-2">Undraggable List</h1>
<div className="flex flex-col gap-y-1">
{array.map((item) => (
<List
key={item.id}
id={item.id}
icon={item.icon}
title={item.title}
description={item.description}
/>
))}
</div>
</div>
);
}
export default App;
import { RiDraggable } from "react-icons/ri";
type ListProps = {
id: string;
icon: JSX.Element;
title: string;
description: string;
};
export const List = (props: ListProps) => {
return (
<div className="flex items-center p-4 border-2 border-green-200">
<div className="cursor-grab p-2">
<RiDraggable size={"22px"} />
</div>
<div className="flex items-center gap-x-4">
{props.icon}
<div className="font-bold">{props.title}</div>
<div className="">{props.description}</div>
</div>
</div>
);
};
Dragできるようにしよう
まずはハンドルを掴んで要素をDragできるようにします。まだDropはできません。
List.tsx
に次のような関数を追加します。
el
にはDragしたい要素全体が該当するように、dragHandleEl
にはHandleとして掴みたい要素が該当するように設定してください。
import { useEffect, useRef } from "react";
import invariant from "tiny-invariant";
import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
~~
const itemRef = useRef<HTMLDivElement | null>(null);
const dragHandleRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = itemRef.current;
const dragHandleEl = dragHandleRef.current;
invariant(el);
invariant(dragHandleEl);
draggable({
element: el,
dragHandle: dragHandleEl,
});
}, []);
ここでは直感的に理解できる関数しか出てきませんので大した説明はしません。
draggable()
にelementプロパティ
を含んだオブジェクトを突っ込むとDragできるようになるよってことがわかればOKです👍️ また、dragHandle
は任意です。
少し趣向を凝らしていきましょう。
次の機能を追加します。
- Drag中にDragされている要素を薄く表示する
- 掴んでいる要素のタイトルだけを表示する
まずは次のようなフラグを追加して、スタイルを管理しましょう。
+ const [isDragging, setIsDragging] = useState(false);
~~~
return (
<div
ref={itemRef}
className={`flex items-center p-4 border-2 border-green-200 ${
+ isDragging && "opacity-20"
}`}
>
~~~
これでsetIsDragging
を使ってフラグの切り替えをすればスタイルが切り替わるようになりました。useEffect
でフラッグの切り替えを行っていきましょう。
さらに、現在の実装だと要素全てがカーソルについてきて少し邪魔ですので、タイトルだけついてきてもらうようにしましょう。
同ファイルのuseEffect
内に次のコードを追加します。
+ import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
+ import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
useEffect(() => {
const el = itemRef.current;
const dragHandleEl = dragHandleRef.current;
invariant(el);
invariant(dragHandleEl);
draggable({
element: el,
dragHandle: dragHandleEl,
onDragStart() {
setIsDragging(true);
},
+ onDrag() {
+ setIsDragging(true);
+ },
+ onDrop() {
+ setIsDragging(false);
+ },
+ onGenerateDragPreview({ nativeSetDragImage }) {
+ setCustomNativeDragPreview({
+ nativeSetDragImage,
+ getOffset: pointerOutsideOfPreview({ x: "16px", y: "16px" }),
+ render({ container }) {
+ container.innerHTML = `${props.title}`;
+ },
+ });
},
});
}, []);
各プロパティ、関数の説明
onDragStart
, onDrag
, onDrop
それぞれDragが始まったとき、Drag中、Dropしたときに発火する関数を設定できます。今回の例ではisDragging
が変更されています。
onGenerateDragPreview
ドラッグプレビューを生成する際に呼び出されるコールバック関数です。
この中で、setCustomNativeDragPreview
関数を使い、ドラッグプレビューをカスタマイズします。
- パラメータ
{ nativeSetDragImage }
ブラウザのネイティブのsetDragImage
メソッド(ドラッグ時に表示されるイメージを設定するAPI)を提供します。これを利用することで、カスタムプレビューを設定可能です。
setCustomNativeDragPreview
関数にぶちこむだけなので特に気にする必要はありません。
setCustomNativeDragPreview
この関数でカスタムドラッグプレビューを設定します。
今回は、Dragしている要素のタイトルだけ表示したいのでしたね。引数としてオブジェクトを渡し、そのプロパティでプレビューを具体的に定義します。
では、具体的なプロパティを見ていきましょう。
-
nativeSetDragImage
前述のnativeSetDragImage
をそのまま渡します。これにより、カスタムプレビューがブラウザのドラッグプレビューとして適用されます。 -
getOffset
プレビューのオフセット(位置)を設定します。
pointerOutsideOfPreview
は、カーソルの位置とプレビューのズレを定義するためのヘルパー関数です。今回はプレビューがカーソルからx: "16px", y: "16px"
だけ離れるように設定しています。 -
render
ドラッグプレビューの内容をレンダリングするための関数です。
引数{ container }
はドラッグプレビューを描画するためのHTML要素(DOMノード)を提供します。
今回はcontainer.innerHTML
にprops.title
を挿入しており、ドラッグプレビューとしてタイトルに指定しているテキストが表示されます。
各種プロパティまとめ
はじめましての関数がたくさん出てきたので一旦整理します。
我々が今回注目すべきは、onGenerateDragPreview
内部のgetOffset: pointerOutsideOfPreview
とrender
関数の2点だけです。
それはプレビューをどの位置に出すか、どんなプレビューを出すのか、です。
render
関数に関しては今回は簡略化のためにこのような実装をしていますが、例えば次のようなコードを書けばもっと凝ったプレビューを表示させることもできます。
const CustomPreview = ({ title }: { title: string }) => {
return <div className="font-bold">{title}</div>;
}
onGenerateDragPreview({ nativeSetDragImage }) {
setCustomNativeDragPreview({
nativeSetDragImage,
getOffset: pointerOutsideOfPreview({ x: "16px", y: "16px" }),
render({ container }) {
const root = ReactDOM.createRoot(container);
root.render(<CustomPreview title={props.title} />);
},
});
}
Dropで順番を変えよう
さて、いよいよ真打ちです。DnDでリストの順番を変えていきます。
すぐに順番を変えるコードを書いていきたいところですが、その前に下準備をします。
Dropする対象の情報を処理する関数
List.tsx
のuseEffect
内に次のコードを追加します。
useEffect(() => {
~~~
draggable({
element: el,
dragHandle: dragHandleEl,
+ getInitialData() {
+ return { id: props.id };
+ },
onDragStart() {
setIsDragging(true);
},
onDrag() {
setIsDragging(true);
},
onDrop() {
setIsDragging(false);
},
onGenerateDragPreview({ nativeSetDragImage }) {
setCustomNativeDragPreview({
nativeSetDragImage,
getOffset: pointerOutsideOfPreview({ x: "16px", y: "16px" }),
render({ container }) {
container.innerHTML = `${props.title}`;
},
});
},
});
+ dropTargetForElements({
+ element: el,
+ getData: ({ input, element }) => {
+ const data = {
+ id: props.id,
+ };
+ return attachClosestEdge(data, {
+ input,
+ element,
+ allowedEdges: ["top", "bottom"],
+ });
+ },
+ });
+ }, [props.id]);
各プロパティ、関数の説明
getInitialData()
Dragしている要素の情報を返します。このApp.tsx
でList.tsx
の情報をキャッチする実装を行いますが、その橋渡しをするのがこの関数です。returnするオブジェクトに渡したい情報を詰め込みます。id
などDOMを識別するユニークな値は必ず含めるようにしてください。
dropTargetForElements
この関数は、要素をDrop可能な領域として設定します。つまりDrop先の情報を扱う関数です。
-
element
draggable
のときと同様に対象DOMのref要素を指定します。あまり気にする必要はありません。とりあえず書いておきましょう。 -
getData
Drag中に、Drop可能なデータを計算・設定するための関数です。- 引数:
-
input
: Drag中のポインタやDrop先の詳細情報。 -
element
: Drop先の要素。
-
- 処理:
Drop先の情報をdataオブジェクトに格納し、attachClosestEdge
を利用して、ポインタの位置に基づいて「最も近いEdge」(top
またはbottom
)をデータに追加します。
- 引数:
-
attachClosestEdge
input
とelement
の情報からDrop先でポインタがどのEdge(上部や下部)に近いかを判断し、その情報をdata
に追加します。今回は要素の横にはDropできなくするためにtop
とbottom
を指定しています。
Edge(エッジ)ってなんやねん
Pragmatic DnDではEdgeを使ってDrop先を識別します。Edgeはtop
, bottom
, right
, left
があり、Drag中の要素がDrop先要素のどのEdgeに近いのかを判別し、dataオブジェクトに追加します。
そしてこれら2つの関数は同じタイミングで行われます。このように発火タイミングが同じクリーンアップ関数はcombine
というユーティリティでマージすることが公式から推奨されています。combine
を追加して2つの関数をラップします。
関数の終わりにつけるセミコロンはそのままだとエラーが起きるのでカンマにしておいてくださいね。
+ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
useEffect(() => {
~~~
+ combine(
draggable({
~~~
}),
dropTargetForElements({
~~~
})
+ );
さて、これでList.tsx
からApp.tsx
に渡す、dataオブジェクトの設定とその橋渡しが終わりました。
次はApp.tsx
にList.ts
xからの情報をキャッチして順番を並び替える処理を実装します。こっちは案外簡単です。App.tsx
に次のように追加します。
import { useState, useEffect } from "react";
import { extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { reorderWithEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge";
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
~~~
const [listItem, setListItem] = useState(array);
useEffect(() => {
return monitorForElements({
canMonitor({ source }) {
return source.data && source.data.id !== null;
},
onDrop({ source, location }) {
const target = location.current.dropTargets[0];
if (!target) return;
const sourceData = source.data;
const targetData = target.data;
if (!sourceData || !targetData) return;
const indexOfSource = listItem.findIndex(
(item) => item.id === sourceData.id
);
const indexOfTarget = listItem.findIndex(
(item) => item.id === targetData.id
);
if (indexOfTarget < 0 || indexOfSource < 0) return;
const closestEdgeOfTarget = extractClosestEdge(targetData);
// DOMを更新するためにflushSyncを使用
flushSync(() => {
setListItem(
reorderWithEdge({
list: listItem,
startIndex: indexOfSource,
indexOfTarget,
closestEdgeOfTarget,
axis: "vertical",
})
);
});
},
});
}, [listItem]);
~~~
まずuseState
で配列を管理します。return
以降で展開する部分もstateで管理している変数に変更しておいてください。
そして、monitorForElements
で行われるのが配列を並び替える処理ですので、useEffect
で実行タイミングを制御します。今回依存配列に入るのはlistItem
になりますね。
各プロパティ、関数の説明
reorderWithEdge
あえて、最後の方に記述されるこの関数を最初に紹介します。こいつは必要な情報さえ与えればいい感じに並び替えてくれる、なんかすっごくすごい関数です。
ですのでこの関数を埋めるようにしてその他の関数や処理を書いていきます。
そしてreorderWithEdge
は次のようなプロパティを持つオブジェクトを渡せば動きます。それぞれどんな情報を渡せばよいのかは名前から一目瞭然ですので簡単な説明に留めます。
-
list
変更対象の配列を渡します。1次元配列のみで、多次元配列は与えることができません。 -
startIndex
Dragするオブジェクトのindex
を渡します。 -
indexOfTarget
Drop対象のindexを渡します。 -
closetEdge
Drop先で最も近いEdgeを渡します。 -
axis
挿入する方向をvertical
かhorizontal
から選ぶことができます。
ここまでで足りていない情報は、startIndex
, indexOfTarget
, closestEdgeOfTarget
ですね。この3つを埋めることを目標に実装を進めていきます。
monitorForElements
DnDの操作を監視する関数です。canMonitor
やonDrop
などのコールバックを利用して、Drag操作が可能かどうかの判定やDrop後の処理を記述します。この関数の中に、さきほどのreorderWithEdge
が入っていますね。ではこの中に記述する関数を見ていきましょう。
canMonitor
Dragする要素のdataを監視します。ここではsource
オブジェクトの中にあるid
が存在するかどうかを確認しています。
onDrop
Dropイベントが発生したときに実行されるコールバックです。コードを見ると分割代入でsource
とlocation
がありますが、これらはそれぞれ次の情報を持っています。
-
source
: Dragされている要素の情報を持つオブジェクト -
location
: Dropが発生した位置の情報を持つオブジェクト
さて、私達がほしいのはstartIndex
, indexOfTarget
, closestEdgeOfTarget
の3つでしたね。startIndex
とindexOfTarget
はここで入手することができそうです。
特に難しく考えることはなく、本当にid
が存在し、無効でないかをチェックしたらfindIndex
でそれぞれのindex
を取得しましょう。
extractClosestEdge
List.tsx
でEdge
を与えたのを覚えていますか?
この関数はattach
したEdge
を引っ張り出す関数で、Drop処理の中でついでに行います。もしこの関数を書いて、closestEdge
を設定したのにうまく動作しない場合はattach
側を疑うとよいです。
さあ、これで役者がそろいました。reorderWithEdge
にセットしてローカルで動かしてみましょう。
このようにリストの順番が変えられるようになりましたか?
動いているなら大成功です。お茶でも飲みましょうオチャア(「🍵・ω・)「🍵
Indicatorを表示してListを完成させよう
今のままだと、どこにDropされるのかわからないのであまりUXがよいとは言えませんね。
Drop先のプレビューを表示してくれるIndicator
を表示させて、この実装を終えましょう。
次のようなコードを追加します。
import {
+ type Edge,
attachClosestEdge,
+ extractClosestEdge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
+ import { DropIndicator } from "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box";
~~~
export const List = (props: ListProps) => {
const [isDragging, setIsDragging] = useState(false);
+ const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
~~~
dropTargetForElements({
~~~
+ onDragEnter: ({ self }) => {
+ const currentEdge = extractClosestEdge(self.data);
+ setClosestEdge(currentEdge);
+ },
+ onDrag: ({ self }) => {
+ const currentEdge = extractClosestEdge(self.data);
+ setClosestEdge(currentEdge);
+ },
+ onDragLeave: () => setClosestEdge(null),
+ onDrop: () => setClosestEdge(null),
+ canDrop: (arg) => {
+ if (arg.source.data.id === props.id) {
+ return false;
+ }
+ return true;
+ },
+ })
);
}, [props.id]);
return (
<div
ref={itemRef}
+ className={`relative flex items-center p-4 border-2 border-green-200 ${
isDragging && "opacity-20"
}`}
>
+ {closestEdge && <DropIndicator edge={closestEdge} />}
<div ref={dragHandleRef} className="cursor-grab p-2">
<RiDraggable size={"22px"} />
</div>
各プロパティ、関数の説明
canDrop
この関数では、Dropできるかどうかの設定を行うことができます。
真偽値を返してやる必要があり、false
の場合、Dropできなくなるだけでなく、Indicator
も表示されなくなります。
arg
の中には、input
, element
, source
が入っていますが、source
にDragしている要素にあたる情報が入っているのでそれを使いましょう。
Drop先の情報はprops
かelement
を使います。今回はprops
を用いました。それぞれの中身については開発者ツールで確認してください。
今回はDragしている要素自身にもIndicator
が表示されることを防ぐためにこのような実装をしています。
新しい概念も難しい処理も私の気力もほとんど無いので、新しく出てきたcanDropについてのみ言及しました。残りの処理について軽く説明します。
useState
でEdge
を管理します。Edge型という型があるので一緒にインポートします。
既にEdge
はattach
してあるのであとは引き出して、state
を更新するだけです。
分割代入で得られるself
にはDropターゲットの情報が入っています。
さて、ではreactのHTML部分にDropIndicator
を配置し、closestEdge
を渡しましょう。
ここで1点注意ですが、Indicator
を囲むdiv
にはposition: relative;
を設定してください。
さもないとIndicator
くんが旅に出てしまいますのでご注意を。
最後にタイトルをUndraggableListからDraggableListに変えれば...
完成!
お疲れ様でした。
これでListの順番がDnDで変更できるようになりましたね。
このようになっていればOKです。
長々といろいろ書きましたが、全体的な実装の流れとしては
- 子要素をDragできるようにする
- 子要素のデータをデータを取得し、Edgeを付与する
- 親要素で渡されたデータとEdgeをキャッチし、
reorderWithEdge
で並び替える - Edgeに基づいて
Indicator
を表示し、必要に応じてcanDrop
で制御する
と考えるとスムーズかと思います!
PR
株式会社HRBrainでは、一緒に働く仲間を募集しています!
興味を持っていただいた方はぜひ弊社の採用ページをご確認ください!
HRBrain文化を一緒に作っていきましょう!