はじめに
Next.jsを触ってみたいと、学習用に簡単なアプリを作っており、その中で新たに試したことを記事にしています。
それでこれからNext.js始めてみようという方の参考になれば嬉しいです。
やりたいこと
前回、複数コンテナの並べ替えを記事にしました。
その続きとしてさらに、そのコンテナ自体の並べ替えを行えるように、コンテナの入れ子を作成します。
「はじめてのdnd-kit」シリーズ全4回の4回目です。
前回分はこちら
環境
下記のDocker開発環境にて行います。
イメージ
今回は、コンテナ自体の入れ子になるため、SortableContextの中にさらにSortableContextを入れていきます。
ドキュメントUsageの一番下の形になります。
// Good, nested Sortable contexts with unique `id`s
<DndContext>
<SortableContext items={["A, "B", "C"]}>
<SortableContext items={[1, 2, 3]}>
{/* ... */}
</SortableContext>
</SortableContext>
</DndContext>
サンプルデータ
type SampleTicketType = {
id: string,
title: string,
description: string,
}
type SampleListType = {
id: string,
title: string,
tickets: SampleTicketType[],
}
export type SampleProjectType = {
id: string,
name: string,
description: string,
image_url: string,
lists: SampleListType[],
}
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: [],
},
],
}
コンポーネントの組み合わせ
前回の「その3」の内容にリスト移動用の「SortableContext」「Sortable」を追加しました。
このような組み合わせです。
<DndContext> // ①プロジェクト全体をラップするコンテキスト
<SortableContext> // ②リスト要素をラップするコンテキスト
{projectData.lists.map((list) => ( // ③リスト要素をループで取得
<Sortable> // ④リスト
<SortableContext> // ⑤チケット要素をラップするコンテキスト
<Droppable> // ⑥チケットがない場合のドロップエリア
{list.tickets.map((ticket) => ( // ⑦チケット要素をループで取得
<Sortable> // ⑧チケット
// チケットアイテム
</Sortable>
))}
</Droppable>
</SortableContext>
</Sortable>
))}
</SortableContext>
</DndContext>
const [projectData, setProjectData] = useState<SampleProjectType>(sampleProjectData);
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
return (
<DndContext
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
collisionDetection={customClosestCorners}
id={projectData.id}
>
<SortableContext items={projectData.lists.map((list) => list.id)} id={projectData.id} key={projectData.id}>
<div className='flex justify-center w-screen h-screen gap-8 overflow-auto pt-16 px-4 pb-4'>
{projectData.lists.map((list) => (
<Sortable id={list.id} key={list.id}>
<SortableContext items={list.tickets} key={list.id} id={list.id} strategy={verticalListSortingStrategy}>
<div className='min-h-[600px] min-w-44 border rounded-xl p-4'>
<h2 className='border-b-4 border-base-content'>{list.title}</h2>
<Droppable key={list.id} id={list.id}>
<div className='flex flex-col gap-4 mt-4'>
{list.tickets.map((ticket) => (
<Sortable key={ticket.id} id={ticket.id}>
<SortableItem itemId={ticket.id}/>
</Sortable>
))}
</div>
</Droppable>
</div>
</SortableContext>
</Sortable>
))}
</div>
</SortableContext>
{activeId && (
<DragOverlay>
<SortableItem itemId={activeId} />
</DragOverlay>
)}
</DndContext>
);
衝突検出アルゴリズム
前回の「その3」とほとんど同じですが、リスト移動にも対応するように下記としました。
const customClosestCorners: CollisionDetection = (args) => {
const cornerCollisions = closestCorners(args);
const idLists = projectData.lists.map((list) => list.id);
// リストを動かしている場合は、プロジェクトのコンテナを取得
// チケットを動かしている場合は、リストのコンテナで一番近いものを取得
const closestContainer = cornerCollisions.find((c) => {
if(idLists.includes(String(args.active.id))){
return c.id === projectData.id;
}else{
return idLists.includes(String(c.id));
}
});
// コンテナが見つからない場合は、cornerCollisionsを返す
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;
});
// collisionsがない場合は、closestContainerを返す
if (collisions.length === 0) {
return [closestContainer];
}
// collisionsがある場合は、collisionsを返す
return collisions;
};
〜以下部分的に記載〜
1. リストとチケットどちらの移動かを判定し、一番近いコンテナを取得
・ドラッグ中の対象のid(args.active.id)がリストかを判定
・リストを移動中の場合:一番外側のプロジェクトIDのコンテナをclosestContainerとする
・チケットを移動中の場合:findで最初に見つかったリストIDのコンテナをclosestContainerとする
const cornerCollisions = closestCorners(args);
const idLists = projectData.lists.map((list) => list.id);
// リストを動かしている場合は、プロジェクトのコンテナを取得
// チケットを動かしている場合は、リストのコンテナで一番近いものを取得
const closestContainer = cornerCollisions.find((c) => {
if(idLists.includes(String(args.active.id))){
return c.id === projectData.id;
}else{
return idLists.includes(String(c.id));
}
});
// コンテナが見つからない場合は、cornerCollisionsを返す
if(!closestContainer) return cornerCollisions;
↓下記画像の青いコンテナか、緑のコンテナのいずれかがclosestContainerとなる
2. closestContainerに所属するアイテムだけを抽出
・cornerCollisionsで取得したオブジェクトのcontainerId
を確認し、closestContainerに一致するもののみを取得
・リスト移動中の場合:containerIdはプロジェクトIDになっているため、所属するリスト一覧が取得される
・チケット移動中の場合(移動先にチケットアイテムがある場合):移動先のコンテナに所属するアイテム一覧が取得される
・チケット移動中の場合(移動先にチケットアイテムがない場合):移動先のコンテナに所属するアイテムがないので、collisionsは空となる
// 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;
});
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;
// ドラッグ中のアイテムが存在しないか、リストの移動の場合はリターン
const activeContainerId = active.data.current?.sortable.containerId;
if(!active || activeContainerId === projectData.id) return;
// ドラッグ中のアイテムをアクティブにする
setActiveId(active.id);
}
併せてドロップ後にDragOverlay
を非表示にするためactiveIdを空にします。
function handleDragEnd(event: DragEndEvent) {
setActiveId(null);
onDragOver(チケットの別コンテナへの移動)
リストの移動は処理せず、チケットの「コンテナから別のコンテナへの移動」をDragOverでドラッグ中に移動処理します。
function handleDragOver(event: DragOverEvent){
// リストの移動の場合はリターン
if(event.active.data.current?.sortable.containerId === projectData.id) return;
// 移動情報を取得
const data = getData(event);
if(!data) return;
const {from, to} = data;
// 同じコンテナ内の移動の場合はリターン
if(from.containerId === to.containerId) return;
// 移動先のコンテナがプロジェクトのコンテナの場合は、移動先のコンテナをドロップターゲットにする
if(to.containerId === projectData.id){
to.containerId = event.over?.id;
to.index = NaN;
to.items = NaN;
}
// 移動元と移動先のリストを取得
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 === event.active.id);
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.リスト移動は処理しない
リスト移動は単一コンテナ内の移動のため、DragEndイベントで処理する。
そのため、移動対象のコンテナIDを確認して、プロジェクトIDだった場合(リスト移動の場合)は即時リターン。
// リストの移動の場合はリターン
if(event.active.data.current?.sortable.containerId === projectData.id) return;
2.eventからfrom,toを取得
この部分はgetDataという関数に切り出しました。
- DragEndEventから移動元のactive、移動先のoverを取り出し
- activeから
data.current.sortable
を取り出し - overから
data.current.sortable
を取り出し - form,toデータとして取得
空のリストへの移動の場合、overはdata.current.sortable
がないので、その場合はtoデータをcontainerIdがover.idで、index,itemsが空のデータとして返却
// 移動情報を取得
const data = getData(event);
if(!data) return;
const {from, to} = data;
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;
// データを返す
return {
from: fromData,
to: toData,
};
}
3.別コンテナへの移動かチェック
fromとtoのコンテナが一致する場合は、onDragEndでの処理のためリターン
移動先コンテナがプロジェクトIDのコンテナ(チケットが空のリストへの移動)の場合は、toの中身を空のコンテナとして上書き。
// 同じコンテナ内の移動の場合はリターン
if(from.containerId === to.containerId) return;
// 移動先のコンテナがプロジェクトのコンテナの場合は、移動先のコンテナをドロップターゲットにする
if(to.containerId === projectData.id){
to.containerId = event.over?.id;
to.index = NaN;
to.items = NaN;
}
4.移動元と移動先のリストデータを取得
// 移動元と移動先のリストを取得
const fromList = projectData.lists.find(list => list.id == from.containerId);
const toList = projectData.lists.find(list => list.id == to.containerId);
if(!fromList || !toList) return;
5.移動元と移動先の並び替え後のリストデータを作成
・移動中アイテムを取得
・移動元リストはfilterで移動中のアイテムを除外
・移動先リストは対象の場所に、移動中アイテムを挿入
// 移動するチケットを取得
const moveTicket = fromList.tickets.find(ticket => ticket.id === event.active.id);
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)];
6.並べ替え後のデータへ更新
newFromTickets
とnewToTickets
で新しいlistsを作成。
setProjectData
でデータを更新。
// チケットの順番を更新
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});
onDragEnd(コンテナ内移動)
「リストの移動」と「コンテナ内のチケット移動」はDragEndでドロップ時に処理。
function handleDragEnd(event: DragEndEvent) {
// ドラッグ中のアイテムを非アクティブにする
setActiveId(null);
// 移動情報を取得
const data = getData(event);
if(!data) return;
const {from, to} = data;
// 移動元コンテナと移動先コンテナが違う場合はリターン
if(from.containerId !== to.containerId) return;
if(from.containerId === projectData.id){
// リストの移動
const movedLists = arrayMove(projectData.lists, from.index, to.index);
setProjectData({...projectData, lists: movedLists});
return;
}else{
// チケットの移動
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を取得
onDragOver
と同じ処理
// 移動情報を取得
const data = getData(event);
if(!data) return;
const {from, to} = data;
2.コンテナ内移動かチェック
fromとtoのコンテナが一致しない場合は、onDragOver
での処理のためリターン
チケットでもリストでもonDragEnd
で処理するのは同一コンテナ内の移動になるため。
if(from.containerId !== to.containerId) return;
3.リスト移動の場合の処理
・fromのコンテナIDがプロジェクトIDの場合はリスト移動と判定。
・準備されている関数arrayMove
を使って並べ替え後のlistsを取得
・setProjectData
でデータを更新
if(from.containerId === projectData.id){
// リストの移動
const movedLists = arrayMove(projectData.lists, from.index, to.index);
setProjectData({...projectData, lists: movedLists});
return;
4.チケット移動の場合
・対象のリスト(コンテナ)を取得
・準備されている関数arrayMove
を使って並べ替え後のticketsを取得
・並び替え後のticketsと差し替えた、新しいlistsを作成
・setProjectData
でデータを更新
}else{
// チケットの移動
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});
}
テスト
無事チケットも、リストも入れ替えが出来るようなりました。
さいごに
・少しdnd-kitに慣れてきたのか、そこまで苦労することなく、コンテナの入れ子ができました。
・もっと細かい設定や機能がありますが、基本的なところは学習できたので「はじめてのdnd-kit」シリーズは終わりにしたいと思います。
・また新しいライブラリなどを使う時に記事にしてみます。
参考