🐾

【dnd kit】ドラッグ&ドロップ(DnD)で並び順を変えられるCardをReactで実装する方法

2023/08/23に公開

こんにちは、AIQ株式会社のフロントエンドエンジニアのまさぴょんです!
今回は、ドラッグ&ドロップ(DnD)で並び順を変えられるCardをReactで実装する方法について解説していきます。
今回の実装には、dnd kitというライブラリを活用していますので、その使い方・考え方と、実装方法を説明していきます。

ドラッグ&ドロップ(DnD)機能の要件定義

まずは、今回の実装で必要な要件の定義をまとめます。

  1. ドラッグ & ドロップで並び順を変更できるようにする(SortableなCardたち)。
  2. Project の使用技術は、Next.js, React, TypeScript

Reactで、いくつかドラッグ&ドロップ(DnD)機能を実装するためのライブラリはあるのですが、
調査の結果dnd-kitを採用することにしました。

https://zenn.dev/manase/scraps/f56bd9dbc6687f

dnd-kit の特徴

調査のスクラップ記事でも、まとめましたが、dnd-kitの特徴には、次のようなものがあります。
( React で DnD するなら、dnd kitから引用しています )

  1. 機能が豊富

    • カスタマイズ可能な衝突検知アルゴリズム、複数のアクティベーター、ドラッグ可能オーバーレイ、ドラッグハンドラー等の多くの機能を提供しています
  2. React製

    • 提供されている2つのhooks、 useDraggable と useDroppable を使えば、DOM のラッパーを作成することなくすぐに DnD が実装できます
  3. 外部依存もなくて軽量

    • コアは 10KB ほどに最小化されていて、他の外部ライブラリには依存していません
  4. プリセットがある

    • ソートが必要な場合は、@dnd-kit/sortable を使ってみてと書いてあります

dnd kitを導入する

npm install @dnd-kit/core

# または、、、

yarn add @dnd-kit/core

今回は、ドラッグ & ドロップで並び順を変更できるようにする(SortableなCardたち)ので、
@dnd-kit/sortableも導入します。

npm install @dnd-kit/sortable

# または、、、

yarn add @dnd-kit/sortable

ドラッグ&ドロップ(DnD)で並び順を変えられるCardを実装する

今回実装したSampleComponentを実際に使用すると、次のように表示できます。

今回作成した Componentの特徴は、次のとおりです。

  1. Drag & Drop(DnD)が可能な Card の Componentが、Cardの Dropが可能な Board の上に、配置されている。
  2. Drag & Drop(DnD)で、並び順を変更できる。
  3. CardをDrag中は、CSSが変わり、Darg中であることがパッと見でわかる。
  4. Card内には、Inputフォームがあり、Drag & Drop(DnD)イベントに邪魔されず、入力ができる。

それでは、実際に、ファイルのSrcCodeの紹介と、実装の特徴を説明してきます。

  1. 型定義ファイル
    • dnd kitで使用する idは、stringである必要があるので、Sort用のid:stringな場合のCardの型定義もある。
type.ts
/** Cardの型定義 */
export interface CardType {
  id: number;
  title: string;
  orderKey: number;
}

/** Sort用の id:string な場合のCardの型定義 */
export interface SortableCardType {
  id: string;
  title: string;
  orderKey: number;
}
  1. Drag & Drop(DnD)が可能な Boardの Component

    • useDroppable&setNodeRefで、ドロップ可能なComponentを設定(Wrap)している。
    • DnD できる領域のタグをDndContextで、設定(Wrap)している。
    • DnDで、Sort可能な領域のContext をSortableContextで設定(Wrap)している。
    • useSensorsuseSensorで、DnD領域内のセンサーをカスタムしている。
    • key がindex だと、Sort後の配列の index変更で、Reactが要素の変更を判別できないので、一意なidを渡す。
DndBoard.tsx
import styled from "styled-components";
import { useEffect, useState } from "react";
/** DnD関係の Import */
import {
  closestCorners,
  DndContext,
  useDroppable,
  KeyboardSensor,
  useSensor,
  useSensors,
  MouseSensor,
  TouchSensor,
  DragEndEvent,
} from "@dnd-kit/core";
import {
  SortableContext,
  verticalListSortingStrategy,
  arrayMove,
  sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";

import { CardType, SortableCardType } from "./type";

import DndCard from "./DndCard";

/** Drag & Drop (DnD)が可能な Board */
const DndBoard = () => {
  /** Drag & Drop で並び替えが可能な CardDataList (TestDataSet) */
  const cardList: CardType[] = [
    { id: 1, title: "ロボ玉のぷるぷる日記", orderKey: 2 },
    { id: 2, title: "白桃さんのお家探検", orderKey: 6 },
    { id: 3, title: "まさぴょんのBlog", orderKey: 3 },
    { id: 4, title: "まり玉のハムハム記録", orderKey: 1 },
    { id: 5, title: "ももたんと白桃にゃんのバトル", orderKey: 4 },
    { id: 6, title: "Marissa の English指導", orderKey: 5 },
  ];

  /** CardList の StateData */
  const [cardDataList, setCardList] = useState<CardType[]>([]);

  useEffect(() => {
    // 初回だけ、fetch した CardList の DataSetを Setする
    setCardList(cardList);
  }, []);

  /**
   * useDroppable: ドロップ可能なコンポーネントを作成する Hooks
   * ドロップ可能にしたい領域の DOM要素に対して、ref を渡す
   * ドロップ可能なコンポーネントで一意になるように、ユニークIDを振る
   */
  const { setNodeRef } = useDroppable({
    id: "card_droppable_board",
  });
  /** Drag終了時に発火するEvent */
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;

    /** Active_Card_Id: 動かしている Card の id */
    const activeId = active.id;

    /** Over_Card_Id: 重なっている Card の id */
    const overId = over ? over.id : null;
    const activeIndex = cardDataList.findIndex(
      (card) => card.id === Number(activeId)
    );

    const overIndex = cardDataList.findIndex(
      (card) => card.id === Number(overId)
    );
    // console.log("cardDataList_Update前", cardDataList);

    /** 配列の並び替えを実行する => Indexの変更 */
    const moveCardDataList = arrayMove(cardDataList, activeIndex, overIndex);

    // orderKey を変更する処理 => orderKey に NewIndexをSetする
    moveCardDataList.forEach((card, index) => {
      card.orderKey = index + 1;
    });
    // console.log("moveCardDataList", moveCardDataList);
    // Edit_Dataの順序を変更する
    setCardList(moveCardDataList);
  };

  /** Sensors: DnD領域内のセンサーをカスタムする */
  const sensors = useSensors(
    /** Mouse: マウス操作で、5px以上離れたら */
    useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
    /** Touch: タッチ操作で、5px以上離れたら */
    useSensor(TouchSensor, { activationConstraint: { distance: 5 } }),
    /** Keyboard */
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );

  /** DnD対応のための Sort可能な DataSet => id を String型に変換した DataSet */
  const [sortableCardList, setSortableCardList] = useState<SortableCardType[]>(
    []
  );
  useEffect(() => {
    const copyCardList = JSON.parse(JSON.stringify(cardDataList));
    /**
     * DnD のSort操作のためには、Number型ではなく、String型である必要があるため、
     * => id (number) を String 変換して Sort用の dndId を作成する
     * */
    const sortableCardList = copyCardList.map((card: any) => {
      const dndId = String(card.id);
      card.id = dndId;
      return card;
    });
    console.log("sortableCardList", sortableCardList);
    setSortableCardList(sortableCardList);
  }, [cardDataList]);

  /** Editした  */
  const editTitleCallback = (editId: string, title: string): void => {
    const copyCardList = JSON.parse(JSON.stringify(cardDataList));
    /** editId から、Target の Dataを取得する => Title を Edit内容で、Updateする。 */
    const editData = copyCardList.find(
      (card: CardType) => card.id === Number(editId)
    );
    editData.title = title;
    // Edit_Dataの順序を変更する
    setCardList(copyCardList);
  };

  return (
    <div>
      <h2
        style={{
          textAlign: "center",
        }}
      >
        Task管理ボード
      </h2>
      <hr />
      {/* DnD できる領域のタグを DndContextで、Wrapする => DnD設定をする */}
      <DndContext
        sensors={sensors}
        collisionDetection={closestCorners}
        onDragEnd={handleDragEnd}
      >
        {/* DnDで、Sort可能な領域の Context */}
        <SortableContext
          id={"card_droppable_board"}
          items={sortableCardList}
          strategy={verticalListSortingStrategy}
        >
          {/* DnD 可能な領域 */}
          <div ref={setNodeRef}>
            {sortableCardList.map((card) => {
              return (
                // NOTE: key が index だと、Sort後の配列の Index変更で、Reactが要素の変更を判別できないので、注意する
                <DndCard
                  key={card.id}
                  id={card.id}
                  title={card.title}
                  orderKey={card.orderKey}
                  inputSetter={editTitleCallback}
                />
              );
            })}
          </div>
        </SortableContext>
      </DndContext>
    </div>
  );
};

export default DndBoard;

  1. Drag & Drop(DnD)が可能な Card の Component

    • useSortableを利用して、DragでSort可能なコンポーネントを定義する
    • Drag & Drop の際に使用する Styleを定義して、Componentに設定する。
    • handleKeyDownの処理をカスタムして、Enterを押すと、DnDモードになる状態を防ぐ。
DndCard.tsx
import { memo, useState } from "react";
/** Drag and Drop (DnD) Hooks */
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { SortableCardType } from "./type";
import styled from "styled-components";

interface EditSortableCardType extends SortableCardType {
  inputSetter: (editId: string, title: string) => void;
}

/** Drag & Drop で並び替え対象となる 最小単位の Card_Component */
const DndCard = memo<EditSortableCardType>(
  ({ id, title, orderKey, inputSetter }) => {
    /**
     * NOTE: useSortable を利用して、ドラッグ可能なコンポーネントを定義する
     * id: 一意となる Key を設定する
     * => id は、String型である必要があるので、注意!
     */
    const { attributes, listeners, setNodeRef, transform, isSorting } =
      useSortable({ id });

    /**
     * NOTE: Drag & Drop の際に使用する Style => 立体移動
     * */
    const style = {
      transform: CSS.Translate.toString(transform), // Outputs => `translate3d(x, y, 0)`
      cursor: "grab",
      border: isSorting ? "1px solid rgba(0, 0, 0, 0.12)" : "",
      boxShadow: isSorting ? "0px 4px 8px rgba(0, 0, 0, 0.2)" : "",
      backgroundColor: "rgb(246, 246, 246)",
    };

    const [cardTitle, setCardTitle] = useState<string>(title);

    /**
     * NOTE: Component内の State_Update & 上の階層の 親_State_Update(cardDataList)する
     *  */
    const inputTitle = (e: React.ChangeEvent<HTMLInputElement>) => {
      setCardTitle(e.target.value);
      inputSetter(id, e.target.value);
    };

    /**
     * NOTE: DnD領域での、Enterキーを無効化する。
     * => inputタグで、文字入力中に、Enterを押すと、DnDモードになってしまうため。
     */
    const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
      if (event.key === "Enter") {
        // Enter_Event_Cancel
        event.preventDefault();
      }
    };

    return (
      <div>
        {/* attributes、listenersはDOMイベントを検知するために使用する */}
        <div
          ref={setNodeRef}
          {...listeners}
          {...attributes}
          style={style}
          onKeyDown={handleKeyDown}
        >
          {/* String型の id で、要素を識別する */}
          <div id={id}>
            <TextInputWrapper>
              <input
                type="text"
                value={cardTitle}
                onChange={(e) => {
                  inputTitle(e);
                }}
              />
            </TextInputWrapper>
          </div>
        </div>
      </div>
    );
  }
);

/**  App_ComponentでのStyle */
const TextInputWrapper = styled.div`
  /* 入力フォームのStyle */

  input {
    font-size: 16px;
  }

  input[type="text"],
  input[type="email"] {
    box-sizing: border-box;
    width: 60%;
    display: block;
    margin: 0 auto;
    border-radius: 5px;
    line-height: 2rem; /* 高さを確保する */
    border: 1px solid #cccccc;
    padding: 5px 8px;
  }
`;

export default DndCard;

まとめ

上記のシンプルな実装で、高度なDrag & Drop の実装ができるので、dnd kitおすすめです🔥

注意事項

この記事は、AIQ 株式会社の社員による個人の見解であり、所属する組織の公式見解ではありません。

参考・引用

  1. React でドラッグ&ドロップ(DnD)を実装する方法の調査

  2. React で DnD するなら、dnd kit

  3. [dnd kit] ドラッグしている要素をドロップするまで、元の要素を表示し続ける方法

  4. dnd kit Doc

  5. dnd kit でソート可能アイテム内にボタンがある時にボタンのクリックとアイテムのソートを両方できるようにする

  6. [dnd kit]ソート可能な要素のクリックイベントを動くようにする

  7. Reactのドラッグ&ドロップライブラリ「dnd kit」を使ってかんばんボードを作る

求む、冒険者!

AIQ株式会社では、一緒に働いてくれるエンジニアを絶賛、募集しております🐱🐹✨

エンジニア視点での我が社のおすすめポイント

  1. フルリモート・フルフレックスの働きやすい環境!
    • 前の会社でアサインしてた現場は、フル出社だったので、ありがたすぎる。。。
    • もうフル出社には、戻れなくなります!
  2. 経験豊富なエンジニアの先輩方
    • 私は、3年目の駆け出しエンジニアなので、これが、かなりありがたいです!
  3. 自社開発とR&D(受託開発)を両方している会社なので、経験できる技術が多い。
    • 自社のProduct開発と、他社からの受託案件で、いろいろな技術を学ぶことができます。
  4. AI関連の最新の技術に触れられるチャンスが多い。
    • 自社で特許を持つほど、AI技術に強い会社で、プロファイリングを得意とした技術体系があります。
    • ChatGPTを自社アプリに搭載など、AIトレンドも、もちろん追っており、最新の技術に触れられるチャンスが多いです。
  5. たまに、札幌ラボ(東京から札幌) or 東京オフィス(札幌から東京)に出張で行ける!
    • 東京と、札幌に2拠点ある会社なので、会合などで集まる際に、出張で行けます。

採用技術 (一部抜粋)

  • FrontEnd: TypeScript, JavaScript, React.js, Vue.js, Next.js, Nuxt.js など
  • BackEnd: Node.js, Express,Python など
  • その他技術: Docker, AWS, Git, GitHub など

エントリー方法

  1. 私達と東京か札幌で一緒に働ける仲間を募集しています。
    詳しくは、Wantedly (https://www.wantedly.com/companies/aiqlab)を見てみてください。

Webエンジニア向け説明

https://www.wantedly.com/projects/1089410

データサイエンティスト向け説明

https://www.wantedly.com/projects/1089406

人事に直通(?)・ご紹介Plan(リファラル採用)

私経由で、ご紹介もできますので、興味のある方や気軽にどんな会社なのか知りたい方は、X(旧:Twitter)にて、DMを送ってくれても大丈夫です。
https://twitter.com/masanyon1212

AIQ Tech Blog (有志)

Discussion