【dnd-kit】ドラッグ&ドロップで快適な並び替え🎵

【dnd-kit】ドラッグ&ドロップで快適な並び替え🎵

投稿日: 2025年03月11日

Tips
学習振り返り
要約
  • ドラッグ&ドロップ機能を持つ麹レシピ共有アプリを実装し、既存のコンポーネントを分割して整理した。
  • dnd-kitライブラリを利用して、要素の並び替えやスマートフォン対応を実現し、簡単に処理することができた。
  • 公式ドキュメントを参考にし、自分のコードに適応させた結果、満足のいく機能が実装できた。

はじめに

ずっとやってみたかったドラッグ&ドロップを実装しました。
ハマったポイント等を含めて振り返ります。

やったこと

麹レシピ共有アプリのレシピ手順を入力するテキストエリアをいじっていて、手順ごとに入力も出来るようにしました。

【dnd-kit】drag&drop実装|ShiftBブログ

あとから足した工程の順番を入れ替えたい事って普通にあるよなぁと思い、入れ替えられるようにしました。

ライブラリ

ドラッグ&ドロップの実装を簡単にするライブラリは沢山あるようだったのですが、調べた結果dnd-kitを使用することにしました。

理由は、

  • npmのダウンロード数が緩やかではあるものの右肩上がりであること

  • 最終更新が3ヶ月前とメンテナンスされていること

  • React用に構築されていること

  • スマホ対応も簡単そう(ほぼすることない)だったこと

以上です。



準備

元々ドラッグ&ドロップで並び替えする前提で作っていなくて、あとから思いついたのでコンポーネントの分割から始めました。

必須ではないと思いますが、私は個人的に一つのファイルの行数が100行超えると長く感じてしまうのでできるだけキリのいいところで分割しようとします。

今回は手順がstepごとに登録できる場合の手順のエリアはコンポーネント一つだけだったのですが、ドラッグ&ドロップでつかめる要素(移動する要素≒1行)をSortableTipsItem.tsxとして別コンポーネントにしました。

手順

まずはどんな感じで使うのか公式ドキュメントを読みました。

その後、クイックスタート等のサンプルを見て、今回は使えそうだったので一旦深いことは考えずにそのまま自分の既存コードに足していきました。

結果、要素は動くけど挙動はなんか変でそのままではおかしいことが確認できたので、一つ一つの処理の内容を見ながら変えるべき場所を探しました。

最終的なコード

ドラッグ&ドロップできるエリア(InputSupport.tsx)

"use client";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPlusCircle } from "@fortawesome/free-solid-svg-icons";
import { useInputSuport } from "../_hooks/useInputSupport";
import { SortableTipsItem } from "./SortableTipsItem";
import { DndContext, closestCenter } from "@dnd-kit/core";
import {
  SortableContext,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
interface Props {
  value: string;
  setValue: (value: string) => void;
}
export const InputSupport: React.FC<Props> = ({ value, setValue }) => {
  const {
    handleNumberChange,
    addStep,
    deleteStep,
    updateStep,
    steps,
    textAreaRefs,
    sensors,
    handleDragEnd,
  } = useInputSuport({ value, setValue });

  return (
    <>
      <DndContext
        sensors={sensors}
        collisionDetection={closestCenter}
        onDragEnd={handleDragEnd}
      >
        <SortableContext
          items={steps.map((_, index) => index.toString())}
          strategy={verticalListSortingStrategy}
        >
          {steps.map((step, index) => (
            <SortableTipsItem
              deleteStep={deleteStep}
              handleNumberChange={handleNumberChange}
              id={index.toString()}
              step={step}
              index={index}
              textAreaRefs={textAreaRefs}
              updateStep={updateStep}
              key={index}
            />
          ))}
        </SortableContext>
      </DndContext>
      <div className="flex justify-end">
        <button type="button" onClick={addStep}>
          <FontAwesomeIcon
            icon={faPlusCircle}
            className="text-dark_brown text-4xl"
          />
        </button>
      </div>
    </>
  );
};

ソートされるアイテム(SortableTipsItem.tsx)

import React, { RefObject } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash, faGripVertical } from "@fortawesome/free-solid-svg-icons";

interface Props {
  id: string;
  index: number;
  handleNumberChange: (index: number, newValue: string) => void;
  updateStep: (index: number, text: string) => void;
  step: {
    index: string;
    text: string;
  };
  deleteStep: (index: number) => void;
  textAreaRefs: RefObject<HTMLTextAreaElement[]>;
}
export const SortableTipsItem: React.FC<Props> = ({
  id,
  index,
  handleNumberChange,
  updateStep,
  step,
  deleteStep,
  textAreaRefs,
}) => {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
    setActivatorNodeRef,
  } = useSortable({ id });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
  };

  return (
    <div
      ref={setNodeRef}
      style={style}
      className={isDragging ? "z-20 cale-105" : ""}
    >
      <div className="w-full flex justify-between items-center gap-1 ">
        <div
          ref={setActivatorNodeRef}
          className="p-2 w-6 h-full flex items-center justify-center cursor-grab touch-none"
          {...attributes}
          {...listeners}
        >
          <FontAwesomeIcon
            icon={faGripVertical}
            className="text-dark_brown text-lg"
          />
        </div>
        <input
          type="text"
          value={step.index}
          className="w-5"
          onChange={e => handleNumberChange(index, e.target.value)}
        />
        <textarea
          className={`border-[1px] border-dark_brown w-11/12 mb-3 rounded-md ${
            index === 0 && "mr-[21.5px]" //gap-14pxfaTrash17.5px
          }`}
          onChange={e => updateStep(index, e.target.value)}
          value={step.text}
          ref={el => {
            if (el) textAreaRefs.current[index] = el;
          }}
        />
        {index !== 0 && (
          <button type="button" onClick={() => deleteStep(index)}>
            <FontAwesomeIcon
              icon={faTrash}
              className="text-dark_brown text-xl "
            />
          </button>
        )}
      </div>
    </div>
  );
};

機能部分

手順を分割して行う時に表示されるコンポーネントで使用するカスタムフック(useInputSuport.ts)

今回フリー入力のテキストエリアと同じカラム(DBの話)使用する関係でstepごとのステートの管理とフォーム管理とが別々なので、いちいちフォーマット整えてsetValueしていて結構ややこしいことをしています💦
最初はこんな処理をするつもりなくてstring型になっているカラムに登録するためにです。
そのあたりの処理は端折ってドラッグ&ドロップで関係あるところだけ残しているので読みにくかったらすみません・・

"use client";
import { useEffect, useRef, useState } from "react";
import { parseSteps } from "@/app/_utils/formatSteps";
import {
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  DragEndEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
export const useInputSuport = ({
  value,
  setValue,
}: {
  value: string;
  setValue: (value: string) => void;
}) => {
  const [steps, setSteps] = useState<{ index: string; text: string }[]>([
    { index: "1", text: "" },
  ]);

  const textAreaRefs = useRef<HTMLTextAreaElement[]>([]);
  const prevStepCount = useRef<number>(steps.length);

  const updateStep = (index: number, text: string) => {
    const newSteps = [...steps];
    newSteps[index].text = text;
    setSteps(newSteps);
    updateFormValue(newSteps);
  };

 //関数定義しているけどほぼ省略
  const renumberSteps = (steps: { index: string; text: string }[]) => {
    let stepCounter = 1;
    return steps.map(step => {
      if (/^\d+$/.test(step.index)) {
        return { ...step, index: (stepCounter++).toString() };
      }
      return step;
    });
  };

  //以下ドラッグ&ドロップ用機能
  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    if (!over) return;
    if (active.id === over.id) return;
    const oldIndex = parseInt(active.id.toString(), 10);
    const newIndex = parseInt(over.id.toString(), 10);
    const newSteps = renumberSteps(arrayMove(steps, oldIndex, newIndex));
    setSteps(newSteps);
    updateFormValue(newSteps);
  };
  return {
    handleNumberChange,
    addStep,
    deleteStep,
    updateStep,
    steps,
    setSteps,
    textAreaRefs,
    updateFormValue,
    sensors,
    handleDragEnd,
  };
};

配列内の移動処理

実装前に一番気になっていたのが、並び替えた時にどこからどこへ移動したかの位置はライブラリの機能で提供してもらえそうだけど、実際に配列の移動ってどうやるんだろうと思っていました。

普通にロジック考えると、

  1. 掴んだ要素を仮置き場としての変数に代入する

  2. 掴んだ要素を削除する

  3. 置いておいた値を移動後の位置に挿入する(2で順番変わる可能性考慮する必要あり)

といった流れかなと思いつつ、もっと簡単に一発でできるような方法あるんだろうかと考えながら息子の隣でググっていました。

結論

ライブラリが提供してくれる関数にあったんです。

const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    if (!over) return;
    if (active.id === over.id) return;//同じ位置ならreturn
    const oldIndex = parseInt(active.id.toString(), 10);//どこから移動したか
    const newIndex = parseInt(over.id.toString(), 10);//どこへ移動したか
    const newSteps = renumberSteps(arrayMove(steps, oldIndex, newIndex));
    setSteps(newSteps);
    updateFormValue(newSteps);
  };

この関数内の

arrayMove(steps, oldIndex, newIndex)

これで一発でした。楽々過ぎてビビりました。

arrayMove

  1. 第一引数→移動処理したい配列

  2. 第二引数→移動前のインデックス番号

  3. 第三引数→移動後のインデックス番号

こでだけで移動完了です。
今回はその後フォーム管理の関係で2つの関数でさらに処理していますが、これだけで配列の移動処理が完了してビビりました。

スマホ対応

ここちょっとハマりました。
dnd-kitではスマホ対応は特に必要ないはずなのですが、なんかうまくいかなかくてちょっと悩みました。

結論

つまみ部分の要素のclassNameにtouch-noneを指定するだけでした。

ドラッグされている要素

useSortableからisDraggingが提供されており、この値がtrueの場合に指定したいCSSがあればスタイルを変えることが出来ます。

className={isDragging ? "z-20 cale-105" : ""}

z-index指定しないと選択されている要素が背面に来てしまうのでこの設定は必須だと思います。

つまみの部分

最初は要素全体にしようとしていたのですが、inputタグやtextareaを含んでいる要素なのでつまみは用意した方が良さそうだと思い、そのようにしました。

<div
          ref={setActivatorNodeRef}
          className="p-2 w-6 h-full flex items-center justify-center cursor-grab touch"
          {...attributes}
          {...listeners}
        >
          <FontAwesomeIcon
            icon={faGripVertical}
            className="text-dark_brown text-lg"
          />
        </div>

要素のref属性にsetActivatorNodeRefを渡し、attributeslistenersを適用します。
デザインはどうしたらいいかわからなかったのでAIに相談して、FontAwesomeIconのfaGripVerticalがいいと教えてもらってこれにしました。

おわりに

ずっと気になっていた機能なので出来て満足です。

やはり公式のサンプルを元にまず実装してみて、その後自分のやりたい処理に合わせてカスタマイズするのが一番早い気がしました。
でも、公式サンプルはアロー関数になっていないとか、早期リターンできるのに{}内に処理書いてて気に入らないとかあるので、そのあたりはちゃんと書き直します。

一昨日手順をステップごとに入力(入力サポートモード、略して採番モードと呼んでいる)できるようにして、ドラッグ&ドロップしたいなぁと思いながら就寝しました。

そして昨日の22時くらいに完成したので(もちろん昼間は1歳児を生かして、夕方には小1帰宅し寝かしつけまで2人の世話を一人でやってます)、1日かからず出来ました。

やはりライブラリの力は強力です・・!!!!

他のライブラリでReact 18になって突然動かなくなった(要対応)とかそういった情報も見たのでどれ使うかは結構悩みました!
いい選択だったと今のところ思っています💪
参考になれば嬉しいです。

シェア!

Threads
user
吉本茜
山口在住/二児の母/育休中
Loading...
記事一覧に戻る
Threads
0