2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Next.js]はじめてのdnd-kit その3(複数コンテナ並べ替え)

Last updated at Posted at 2024-09-08

はじめに

Next.jsを触ってみたいと、学習用に簡単なアプリを作っており、その中で新たに試したことを記事にしています。
それでこれからNext.js始めてみようという方の参考になれば嬉しいです。

やりたいこと

カンバンアプリのように、複数のコンテナの中のアイテムの並べ替えをドラッグアンドドロップで行いたい。

前回、単一コンテナの並べ替えを記事にしました。
その続きとして複数コンテナでの並べ替えを行っていきます。

「はじめてのdnd-kit」シリーズ全4回の3回目です。

過去分はこちら

続きはこちら

環境

下記のDocker開発環境にて行います。

参考

こちらを参考にさせていただきました。ありがとうございます!!!

イメージ

複数コンテナ2.png

・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つの角より、別リストのチケットの角の方が近いという判定になってしまう)

リストに入れれない.gif

そのため、移動しようとしているリスト以外のチケットアイテムは除外するという独自のアルゴリズムを作成します。

  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});

テスト

無事コンテナを跨いでチケットの入れ替えが出来るようなりました。

テスト用.gif

つづき

さいごに

・複数コンテナの並び替えが何とかできました。
・センサーのカスタマイズや、SotableContextをネストしてリスト自体の入れ替えをしたりと、実際のアプリで使えるようにもっと理解が必要だなと感じました。

参考

2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?