【dnd kit】ドラッグ&ドロップ(DnD)で並び順を変えられるCardをReactで実装する方法
こんにちは、AIQ株式会社のフロントエンドエンジニアのまさぴょんです!
今回は、ドラッグ&ドロップ(DnD)で並び順を変えられるCardをReactで実装する方法について解説していきます。
今回の実装には、dnd kit
というライブラリを活用していますので、その使い方・考え方と、実装方法を説明していきます。
ドラッグ&ドロップ(DnD)機能の要件定義
まずは、今回の実装で必要な要件の定義をまとめます。
- ドラッグ & ドロップで並び順を変更できるようにする(SortableなCardたち)。
- Project の使用技術は、Next.js, React, TypeScript
Reactで、いくつかドラッグ&ドロップ(DnD)機能を実装するためのライブラリはあるのですが、
調査の結果dnd-kit
を採用することにしました。
dnd-kit の特徴
調査のスクラップ記事でも、まとめましたが、dnd-kit
の特徴には、次のようなものがあります。
( React で DnD するなら、dnd kitから引用しています )
-
機能が豊富
- カスタマイズ可能な衝突検知アルゴリズム、複数のアクティベーター、ドラッグ可能オーバーレイ、ドラッグハンドラー等の多くの機能を提供しています
-
React製
- 提供されている2つのhooks、 useDraggable と useDroppable を使えば、DOM のラッパーを作成することなくすぐに DnD が実装できます
-
外部依存もなくて軽量
- コアは 10KB ほどに最小化されていて、他の外部ライブラリには依存していません
-
プリセットがある
- ソートが必要な場合は、@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の特徴は、次のとおりです。
- Drag & Drop(DnD)が可能な Card の Componentが、Cardの Dropが可能な Board の上に、配置されている。
- Drag & Drop(DnD)で、並び順を変更できる。
- CardをDrag中は、CSSが変わり、Darg中であることがパッと見でわかる。
- Card内には、Inputフォームがあり、Drag & Drop(DnD)イベントに邪魔されず、入力ができる。
それでは、実際に、ファイルのSrcCodeの紹介と、実装の特徴を説明してきます。
- 型定義ファイル
-
dnd kit
で使用するid
は、string
である必要があるので、Sort用のid:string
な場合のCardの型定義もある。
-
/** Cardの型定義 */
export interface CardType {
id: number;
title: string;
orderKey: number;
}
/** Sort用の id:string な場合のCardの型定義 */
export interface SortableCardType {
id: string;
title: string;
orderKey: number;
}
-
Drag & Drop(DnD)が可能な Boardの Component
-
useDroppable
&setNodeRef
で、ドロップ可能なComponentを設定(Wrap)している。 - DnD できる領域のタグを
DndContext
で、設定(Wrap)している。 - DnDで、Sort可能な領域のContext を
SortableContext
で設定(Wrap)している。 -
useSensors
とuseSensor
で、DnD領域内のセンサーをカスタムしている。 - key が
index
だと、Sort後の配列のindex
変更で、Reactが要素の変更を判別できないので、一意なid
を渡す。
-
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;
-
Drag & Drop(DnD)が可能な Card の Component
-
useSortable
を利用して、DragでSort可能なコンポーネントを定義する - Drag & Drop の際に使用する Styleを定義して、Componentに設定する。
-
handleKeyDown
の処理をカスタムして、Enterを押すと、DnDモードになる状態を防ぐ。
-
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 株式会社の社員による個人の見解であり、所属する組織の公式見解ではありません。
参考・引用
求む、冒険者!
AIQ株式会社では、一緒に働いてくれるエンジニアを絶賛、募集しております🐱🐹✨
エンジニア視点での我が社のおすすめポイント
- フルリモート・フルフレックスの働きやすい環境!
- 前の会社でアサインしてた現場は、フル出社だったので、ありがたすぎる。。。
- もうフル出社には、戻れなくなります!
- 経験豊富なエンジニアの先輩方
- 私は、3年目の駆け出しエンジニアなので、これが、かなりありがたいです!
- 自社開発とR&D(受託開発)を両方している会社なので、経験できる技術が多い。
- 自社のProduct開発と、他社からの受託案件で、いろいろな技術を学ぶことができます。
- AI関連の最新の技術に触れられるチャンスが多い。
- 自社で特許を持つほど、AI技術に強い会社で、プロファイリングを得意とした技術体系があります。
- ChatGPTを自社アプリに搭載など、AIトレンドも、もちろん追っており、最新の技術に触れられるチャンスが多いです。
- たまに、札幌ラボ(東京から札幌) or 東京オフィス(札幌から東京)に出張で行ける!
- 東京と、札幌に2拠点ある会社なので、会合などで集まる際に、出張で行けます。
採用技術 (一部抜粋)
- FrontEnd: TypeScript, JavaScript, React.js, Vue.js, Next.js, Nuxt.js など
- BackEnd: Node.js, Express,Python など
- その他技術: Docker, AWS, Git, GitHub など
エントリー方法
- 私達と東京か札幌で一緒に働ける仲間を募集しています。
詳しくは、Wantedly (https://www.wantedly.com/companies/aiqlab)を見てみてください。
Webエンジニア向け説明
データサイエンティスト向け説明
人事に直通(?)・ご紹介Plan(リファラル採用)
私経由で、ご紹介もできますので、興味のある方や気軽にどんな会社なのか知りたい方は、X(旧:Twitter)にて、DMを送ってくれても大丈夫です。
AIQ 株式会社 に所属するエンジニアが技術情報をお届けします。 ※ AIQ 株式会社 社員による個人の見解であり、所属する組織の公式見解ではありません。 Wantedly: wantedly.com/companies/aiqlab
Discussion