はじめに
Next.jsを触ってみたいと、学習用に簡単なアプリを作っており、その中で新たに試したことを記事にしています。
それでこれからNext.js始めてみようという方の参考になれば嬉しいです。
やりたいこと
カンバンアプリのように、複数のコンテナの中のアイテムの並べ替えをドラッグアンドドロップで行いたい。
前回、単一コンテナの並べ替えを記事にしました。
その続きとして複数コンテナでの並べ替えを行っていきます。
「はじめてのdnd-kit」シリーズ全4回の3回目です。
過去分はこちら
続きはこちら
環境
下記のDocker開発環境にて行います。
参考
こちらを参考にさせていただきました。ありがとうございます!!!
イメージ
・1つのDndContext
の中に、複数のSortableContext
を配置します。
・空の列にもドロップ出来るようにSortableContext
の中にDroppable
を配置し、さらにその中にuseSortableで作成した並べ替えアイテムを配置していきます。
サンプルデータ
サンプルデータは1つのプロジェクトの中に、いくつかのリストがあり、その中にいくつかのチケットが入っているものにします。
export const sampleProjectData =
{
id: 'PJ1',
name: 'Project 1',
description: 'This is project 1',
image_url: '',
lists: [
{
id: 'L1',
title: 'List 1',
tickets: [
{id: 'T1', title: 'Ticket 1', description: 'This is ticket 1'},
{id: 'T2', title: 'Ticket 2', description: 'This is ticket 2'},
{id: 'T3', title: 'Ticket 3', description: 'This is ticket 3'},
],
},
{
id: 'L2',
title: 'List 2',
tickets: [
{id: 'T4', title: 'Ticket 4', description: 'This is ticket 4'},
{id: 'T5', title: 'Ticket 5', description: 'This is ticket 5'},
{id: 'T6', title: 'Ticket 6', description: 'This is ticket 6'},
],
},
{
id: 'L3',
title: 'List 3',
tickets: [
{id: 'T7', title: 'Ticket 7', description: 'This is ticket 7'},
{id: 'T8', title: 'Ticket 8', description: 'This is ticket 8'},
{id: 'T9', title: 'Ticket 9', description: 'This is ticket 9'},
],
},
{
id: 'L4',
title: 'List 4',
tickets: [],
},
],
}
コンポーネントの組み合わせ
その2で使ったコンポーネントをそのまま使います。
const [projectData, setProjectData] = useState<ProjectDetail>(sampleProjectData);
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
return (
<DndContext
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
collisionDetection={customClosestCorners}
id={projectData.id}
>
<div className='flex justify-center items-center w-screen h-screen gap-8'>
{projectData.lists.map((list) => (
<SortableContext items={list.tickets} key={list.id} id={list.id} strategy={verticalListSortingStrategy}>
<Droppable key={list.id} id={list.id}>
<div className='flex flex-col gap-8 p-4 border min-h-[600px] min-w-44'>
{list.tickets.map((ticket) => (
<Sortable key={ticket.id} id={ticket.id}>
<SortableItem itemId={ticket.id}/>
</Sortable>
))}
</div>
</Droppable>
</SortableContext>
))}
{activeId && (
<DragOverlay>
<SortableItem itemId={activeId} />
</DragOverlay>
)}
</div>
</DndContext>
);
DndContext
・一番外側に配置し、各ドラッグイベントを設定します(処理については後ほど記載します)
・collisionDetectionで衝突検出アルゴリズムcustomClosestCorners
を設定します。
(イベント処理、衝突検出アルゴリズムについてはこの後で詳細を記載)
SortableContext
・複数リストの配列になっているため、map関数で反復してリスト毎にSortableContextを配置します。
Droppable
・空のリストにもドロップ出来るように、Droppableを配置します。
Sortable,SortableItem
・複数チケットの配列になっているため、map関数で反復してSortable.tsxとSortableItem.tsxを使って配置。
DragOverlay
・コンテナ間の移動はドラッグ中に元のコンテナーからアンマウントし、別のコンテナーに再度マウントが必要
・表示に影響を与えないようにするためにDragOverlay
を利用してドラッグ中のアイテムを表示します。
・ドラッグ中のみDragOverlay
が表示される必要があるので、activeIdで表示/非表示を判別していきます。
衝突検出アルゴリズム
下記ドキュメントにもあるようにclosestCenter
では、Droppableが重なった時に意図しない方を取得してしまう可能性があるのでclosestCorners
の方が望ましいようです。
ドラッグアイテムの4つの角すべてと、各ドロップ可能なコンテナーの4つの角の間の距離を測定して、最も近いものを見つけるものです。
ただ、そのままだと隣接するリスト同士が近いこともあり、空のリストに移動したい時に近い別のリストのチケットを取得してしまう問題があります。
(リストの4つの角より、別リストのチケットの角の方が近いという判定になってしまう)
そのため、移動しようとしているリスト以外のチケットアイテムは除外するという独自のアルゴリズムを作成します。
const customClosestCorners: CollisionDetection = (args) => {
const cornerCollisions = closestCorners(args);
// 一番近いリストのコンテナを取得
const listIds = new Set(projectData.lists.map(list => list.id));
const closestContainer = cornerCollisions.find((c) => {
return listIds.has(c.id.toString())
});
if(!closestContainer) return cornerCollisions;
// closestContainerの中のチケットのみを取得
const collisions = cornerCollisions.filter(({ data }) => {
if(!data) return false;
const droppableData = data.droppableContainer?.data?.current;
if(!droppableData) return false;
const { containerId } = droppableData.sortable;
return closestContainer.id === containerId;
});
// 中身のチケットがない場合は、closestContainerを返す
if (collisions.length === 0) {
return [closestContainer];
}
// 中身のチケットがある場合は、collisionsを返す
return collisions;
};
〜以下部分的に記載〜
1. closestCornersの値を取得
一度closestCornersの値を格納
const cornerCollisions = closestCorners(args);
下記のようなオブジェクトが複数入った配列を取得します。
リスト、チケット全てが取得されます。
{
"id": "T8",
"data": {
"value": 0,
"droppableContainer": {
"disabled": false,
"id": "T8",
"key": "Droppable-34",
"node": "...省略...",
"rect": "...省略...",
"data": {
"current": {
"sortable": {
"containerId": "L3",
"index": 1,
"items": [
"T7",
"T8",
"T9"
]
}
}
}
}
}
}
2. 上記1の中から一番近いコンテナ(リスト)を取得
配列が近い順に渡されるため、findで最初のコンテナ(リスト)を取得(チケットはスキップ)
// 一番近いリストのコンテナを取得
const listIds = new Set(projectData.lists.map(list => list.id));
const closestContainer = cornerCollisions.find((c) => {
return listIds.has(c.id.toString())
});
if(!closestContainer) return cornerCollisions;
3. 上記2のclosestContainerに所属するチケットだけを抽出
オブジェクトの中身を辿ってcontainerId
を確認し、closestContainerに一致するもののみを取得。
リストはdroppableDataがundefined
なのでスキップされ、チケットのみ取得される。
// closestContainerの中のチケットのみを取得
const collisions = cornerCollisions.filter(({ data }) => {
if(!data) return false;
const droppableData = data.droppableContainer?.data?.current;
if(!droppableData) return false;
const { containerId } = droppableData.sortable;
return closestContainer.id === containerId;
});
4. 上記3のcollisionsの中身によって返却内容を判別
collisionsの中身があればcollisionsを返却。
collisionsの中身がなければ(空のリストであれば)そのリストを返却。
// 中身のチケットがない場合は、closestContainerを返す
if (collisions.length === 0) {
return [closestContainer];
}
// 中身のチケットがある場合は、collisionsを返す
return collisions;
DragOverlay用の処理(activeId制御)
DragOverlay
をドラッグ中のみ表示するため、ドラッグ開始時にactiveIdにドラッグ中のアイテムIDを格納します。
function handleDragStart(event: DragStartEvent) {
const {active} = event;
if(!active) return;
setActiveId(active.id);
}
併せてドロップ後にDragOverlay
を非表示にするためactiveIdを空にします。
function handleDragEnd(event: DragEndEvent) {
setActiveId(null);
onDragEnd(コンテナ内移動)
「コンテナ内の移動」はDragEndでドロップ時に処理。
function handleDragEnd(event: DragEndEvent) {
setActiveId(null);
const data = getData(event, projectData);
if(!data) return;
const {from, to} = data;
if(from.containerId !== to.containerId) return;
const list = projectData.lists.find(list => list.id == from.containerId);
if(!list) return;
const newTickets = arrayMove(list.tickets, from.index, to.index);
const newLists = projectData.lists.map(list => {
if(list.id === from.containerId) return {...list, tickets: newTickets};
return list;
});
setProjectData({...projectData, lists: newLists});
}
〜以下部分的に記載〜
1.eventからfrom,toを取得
const data = getData(event, projectData);
if(!data) return;
const {from, to} = data;
この部分はgetDataという関数に切り出しました。
- DragEndEventから移動元のactive、移動先のoverを取り出し
- activeからdata.current.sortableを取り出し
- overからdata.current.sortableを取り出し
- form,toデータとして返却
空のリストへの移動の場合、overはdata.current.sortableがないので、その場合はtoデータをcontainerIdがover.idで、index,itemsが空のデータとして返却
export function getData(event: { active: Active; over: Over | null }, projectData: ProjectDetail) {
const {active, over} = event;
// キャンセルされた、もしくはターゲットがない場合はリターン
if(!active || !over) return;
// ドラッグアイテムとターゲットが同じ場合はリターン
if(active.id === over.id) return;
// activeのデータを取得
const fromData = active.data.current?.sortable;
if(!fromData) return;
// overのデータを取得
const toData = over.data.current?.sortable;
const toDataNotSortable = {
containerId: over.id,
index: NaN,
items: NaN,
}
// データを返す
return {
from: fromData,
to: toData ?? toDataNotSortable,
};
}
2.コンテナ内移動かチェック
fromとtoのコンテナが一致しない場合は、onDragOverでの処理のためリターン
if(from.containerId !== to.containerId) return;
3.リストデータを取得
const list = projectData.lists.find(list => list.id == from.containerId);
if(!list) return;
4.並び替え後のチケット一覧データを取得
準備されている関数arrayMove
を使って並べ替え後のticketsを取得
const newTickets = arrayMove(list.tickets, from.index, to.index);
5.並べ替え後のリスト一覧データを作成
上記4で作成したticketsと差し替えた新しいlistsを作成
const newLists = projectData.lists.map(list => {
if(list.id === from.containerId) return {...list, tickets: newTickets};
return list;
});
5.データを更新
全体のデータをsetProjectData
で更新
setProjectData({...projectData, lists: newLists});
onDragOver(別コンテナへの移動)
「コンテナから別のコンテナへの移動」はDragOverでドラッグ中に移動処理
function handleDragOver(event: DragOverEvent){
const data = getData(event, projectData);
if(!data) return;
const {from, to} = data;
if(from.containerId === to.containerId) return;
const fromList = projectData.lists.find(list => list.id == from.containerId);
const toList = projectData.lists.find(list => list.id == to.containerId);
if(!fromList || !toList) return;
const moveTicket = fromList.tickets.find(ticket => ticket.id === from.items[from.index]);
if(!moveTicket) return;
const newFromTickets = fromList.tickets.filter((ticket) => ticket.id !== moveTicket.id);
const newToTickets = [...toList.tickets.slice(0, to.index), moveTicket, ...toList.tickets.slice(to.index)];
const newLists = projectData.lists.map(list => {
if(list.id === from.containerId) return {...list, tickets: newFromTickets};
if(list.id === to.containerId) return {...list, tickets: newToTickets};
return list;
});
setProjectData({...projectData, lists: newLists});
}
〜以下部分的に記載〜
1.eventからfrom,toを取得
内容はonDragEndと同じ
const data = getData(event, projectData);
if(!data) return;
const {from, to} = data;
2.別コンテナへの移動かチェック
fromとtoのコンテナが一致する場合は、onDragEndでの処理のためリターン
if(from.containerId === to.containerId) return;
3.移動元と移動先のリストデータを取得
const fromList = projectData.lists.find(list => list.id == from.containerId);
const toList = projectData.lists.find(list => list.id == to.containerId);
if(!fromList || !toList) return;
4.移動元と移動先の並び替え後のリストデータを作成
・移動中アイテムを取得
・移動元リストはfilterで移動中のアイテムを除外
・移動先リストは対象の場所に、移動中アイテムを挿入
const moveTicket = fromList.tickets.find(ticket => ticket.id === from.items[from.index]);
if(!moveTicket) return;
const newFromTickets = fromList.tickets.filter((ticket) => ticket.id !== moveTicket.id);
const newToTickets = [...toList.tickets.slice(0, to.index), moveTicket, ...toList.tickets.slice(to.index)];
5.並べ替え後のリスト一覧データを作成
上記4で作成したticketsと差し替えた新しいlistsを作成
const newLists = projectData.lists.map(list => {
if(list.id === from.containerId) return {...list, tickets: newFromTickets};
if(list.id === to.containerId) return {...list, tickets: newToTickets};
return list;
});
5.データを更新
全体のデータをsetProjectData
で更新
setProjectData({...projectData, lists: newLists});
テスト
無事コンテナを跨いでチケットの入れ替えが出来るようなりました。
つづき
さいごに
・複数コンテナの並び替えが何とかできました。
・センサーのカスタマイズや、SotableContextをネストしてリスト自体の入れ替えをしたりと、実際のアプリで使えるようにもっと理解が必要だなと感じました。
参考