11
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

HRBrainAdvent Calendar 2024

Day 11

Pragmatic drag and drop コトハジメ【List編】

Last updated at Posted at 2024-12-10

この記事にたどり着いたあなたはこんなことを思っていたのではないでしょうか?
「PragmaticDnDの日本語解説無くないか!!?PragmaticDnDの解説無い!!!(伝われ)」
かく言う私もこのライブラリがプロダクトに採用され、DnD実装タスクにアサインされてから絶望したものです。
日本語記事一つもねえじゃんって。公式ドキュメントも物足りないって。弱いって。厳しいって。

そんな過去の私のように困っている方の助けになればいいなと思って書いた解説記事です。

はじめに

当記事ではPragmaticDnDのExampleではじめに紹介されるListについてできるだけ詳らかに解説します。
ちょ〜〜〜っと長いですが、ぜひ最後までお付き合いください。

本記事で実装したコードはGitHubにも公開しているのでよければ参考にしてください。

使用スタックは以下の通りです

Get Started

さて、こんなリストを作ってみました。
まだDragで順番を変えることのできないUndraggableなこいつをDraggableにしていきましょう。

スクリーンショット 2024-12-06 14.42.15.png

変更前のコードはこちらです。

App.tsx
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;

List.tsx
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として掴みたい要素が該当するように設定してください。

List.tsx
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.gif

ここでは直感的に理解できる関数しか出てきませんので大した説明はしません。
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.innerHTMLprops.titleを挿入しており、ドラッグプレビューとしてタイトルに指定しているテキストが表示されます。

各種プロパティまとめ

はじめましての関数がたくさん出てきたので一旦整理します。
我々が今回注目すべきは、onGenerateDragPreview内部のgetOffset: pointerOutsideOfPreviewrender関数の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} />);
    },
  });
}

さて、これらの実装を終えるとこんな状態になります。
draggpreview.gif

Dropで順番を変えよう

さて、いよいよ真打ちです。DnDでリストの順番を変えていきます。
すぐに順番を変えるコードを書いていきたいところですが、その前に下準備をします。

Dropする対象の情報を処理する関数

List.tsxuseEffect内に次のコードを追加します。

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.tsxList.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
    inputelementの情報からDrop先でポインタがどのEdge(上部や下部)に近いかを判断し、その情報をdataに追加します。今回は要素の横にはDropできなくするためにtopbottomを指定しています。

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.tsxList.tsxからの情報をキャッチして順番を並び替える処理を実装します。こっちは案外簡単です。App.tsxに次のように追加します。

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
    挿入する方向をverticalhorizontalから選ぶことができます。

ここまでで足りていない情報は、startIndex, indexOfTarget, closestEdgeOfTargetですね。この3つを埋めることを目標に実装を進めていきます。

monitorForElements

DnDの操作を監視する関数です。canMonitoronDropなどのコールバックを利用して、Drag操作が可能かどうかの判定やDrop後の処理を記述します。この関数の中に、さきほどのreorderWithEdgeが入っていますね。ではこの中に記述する関数を見ていきましょう。

canMonitor

Dragする要素のdataを監視します。ここではsourceオブジェクトの中にあるidが存在するかどうかを確認しています。

onDrop

Dropイベントが発生したときに実行されるコールバックです。コードを見ると分割代入でsourcelocationがありますが、これらはそれぞれ次の情報を持っています。

  • source: Dragされている要素の情報を持つオブジェクト
  • location: Dropが発生した位置の情報を持つオブジェクト

さて、私達がほしいのはstartIndex, indexOfTarget, closestEdgeOfTargetの3つでしたね。startIndexindexOfTargetはここで入手することができそうです。
特に難しく考えることはなく、本当にidが存在し、無効でないかをチェックしたらfindIndexでそれぞれのindexを取得しましょう。

extractClosestEdge

List.tsxEdgeを与えたのを覚えていますか?
この関数はattachしたEdgeを引っ張り出す関数で、Drop処理の中でついでに行います。もしこの関数を書いて、closestEdgeを設定したのにうまく動作しない場合はattach側を疑うとよいです。

さあ、これで役者がそろいました。reorderWithEdgeにセットしてローカルで動かしてみましょう。
このようにリストの順番が変えられるようになりましたか?
動いているなら大成功です。お茶でも飲みましょうオチャア(「🍵・ω・)「🍵
reorder.gif

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先の情報はpropselementを使います。今回はpropsを用いました。それぞれの中身については開発者ツールで確認してください。
今回はDragしている要素自身にもIndicatorが表示されることを防ぐためにこのような実装をしています。

新しい概念も難しい処理も私の気力もほとんど無いので、新しく出てきたcanDropについてのみ言及しました。残りの処理について軽く説明します。
useStateEdgeを管理します。Edge型という型があるので一緒にインポートします。
既にEdgeattachしてあるのであとは引き出して、stateを更新するだけです。
分割代入で得られるselfにはDropターゲットの情報が入っています。

さて、ではreactのHTML部分にDropIndicatorを配置し、closestEdgeを渡しましょう。
ここで1点注意ですが、Indicatorを囲むdivにはposition: relative;を設定してください。
さもないとIndicatorくんが旅に出てしまいますのでご注意を。

最後にタイトルをUndraggableListからDraggableListに変えれば...

完成!

お疲れ様でした。
これでListの順番がDnDで変更できるようになりましたね。
このようになっていればOKです。

長々といろいろ書きましたが、全体的な実装の流れとしては

  1. 子要素をDragできるようにする
  2. 子要素のデータをデータを取得し、Edgeを付与する
  3. 親要素で渡されたデータとEdgeをキャッチし、reorderWithEdgeで並び替える
  4. Edgeに基づいてIndicatorを表示し、必要に応じてcanDropで制御する

と考えるとスムーズかと思います!

done.gif

PR

株式会社HRBrainでは、一緒に働く仲間を募集しています!

興味を持っていただいた方はぜひ弊社の採用ページをご確認ください!
HRBrain文化を一緒に作っていきましょう!

11
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?