0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

dnd-kitを使ってTier表機能を作ってみた

0
Posted at

はじめに

 先日個人開発でAniTierというアプリを作成しました。その中でTier表機能を実装するのに用いたdnd-kitというライブラリに関して、書き残そうと思ます。
 ※個人開発の内容に関しては、別途記事を投稿しているので気になる方はこちらも閲覧いただけると幸いです。

dnd-kitとは

 以下のような特徴があるReact向けのドラッグ&ドロップライブラリです。

  • 軽量
  • 拡張性が高い
  • 発火する動作(Mouse/Touch/Keyboard)を自由に設定できる

今回作成したもの

 以下画像のように、アニメをS~Aに分類するTier表を作成しました。

image.png

基本構成

 まずは、dnd-kitで最低限必要な要素について説明します。

DndContext

 DndContextは、ドラッグ&ドロップ機能全体を管理するコンポーネントです。すべてのドラッグ操作は、このコンポーネントの中で行われます。

<DndContext
 onDragStart={handleDragStart}
 onDragOver={handleDragOver}
 onDragEnd={handleDragEnd}
>
 
</DndContext>
  • ドラッグを開始したとき
  • ドラッグ中に別の要素へ重なったとき
  • ドロップしたとき

 などのイベントは、すべてDndContextで受け取ります。特に重要なのがonDragEndです。ドラッグが終了したタイミングでどのカードをどこへ移動したのかを取得し、配列の並び替えや状態更新を行います。

SortableContext

 SortableContextは、並び替え可能なエリアを定義するコンポーネントです。

<SortableContext items={items}>
 {items.map(...)}
</SortableContext>

 例の場合は、SortableContext内に記述された「 {items.map(...)}」で展開された要素が並び替え可能なエリアとなり、そこでは要素の並び順・ドロップ位置などが計算されます。
 今回のTier表では、Sランク~Dランクそれぞれが独立したリストになるため、SortableContextをランク毎に配置した上で、後述するuseDroppableというHookも使用し、そのエリアがドロップ可能なエリアであることを定義する必要があります。

useSortable

 useSortableは、個々の要素をドラッグ可能にするためのHookです。

const {
 attributes,
 listeners,
 setNodeRef,
 transform,
 transition,
} = useSortable({
 id: item.id,
});

 このHookには、以下のようなプロパティを設定することができます。

プロパティ 役割
setNodeRef ドラッグ対象となる要素を登録する
attributes アクセシビリティ用の属性を付与する
listeners マウスやタッチイベントを設定する
transform ドラッグ中の座標を管理する
transition アニメーションを適用する

 例えば以下のように記述すると、その要素は自由にドラッグできるようになります。

<div
 ref={setNodeRef}
 {...listeners}
>
...
<div>

arrayMove

 これまでに挙げた3つの要素を組み合わせることで、エリア内でオブジェクトのドラッグ&ドロップができるようになりました。
 しかし、それだけではTier表を作るには不十分です。何故なら、Tier表は、同一ランクでも左右差をつけることで優劣を表すことがあるからです。arrayMoveを用いることで、同じエリア内での並び替えを実現することができます。

setAnimeList((items) =>
 arrayMove(items, oldIndex, newIndex)
);

実際のコード

 基本的な要素を抑えたところで、次はそれに沿う形で実際のコードを説明しようと思います。なお、Tier表に直接関与しないと判断した部分に関しては一部省略しております。

DndContext

 <DndContext
   sensors={sensors}
   onDragStart={handleDragStart}
   onDragEnd={handleDragEnd}
 >
   <div className="flex flex-col h-screen">
     {/* 省略 */}
     
     <div className="mt-20 flex-1 flex gap-4 px-4 min-h-0">
       {/* Tier 表 */}
       <div className="mt-5 pb-35 sm:pb-5 flex-2 overflow-y-auto">
         <TierBoard
           tiers={tiers}
           tierName={tierName}
           onNameChange={setTierName}
         />
        {/* 省略 */}
       </div>

       {/* AssetPanel */}
       {isDesktop ? (
         // ===== PC =====
         <div
           className="
             hidden lg:flex
             lg:flex-1
             border-l pl-4
             min-h-0
           "
         >
           <div className="flex-1 overflow-y-auto pb-20">
             <AssetPanel />
           </div>
         </div>
       ) : (
         // ===== Mobile =====
         createPortal(
           <AssetPanelMobile
             isOpen={isAssetOpen}
             toggle={() => setIsAssetOpen(!isAssetOpen)}
           />,
           document.body
         )
       )}
     </div>
   </div>

   {/* DragOverlay */}
   {createPortal(
     <DragOverlay
       dropAnimation={null}
     >
       {activeDragData && (
         <div className="w-24 h-24 rounded shadow-lg overflow-hidden bg-gray-100">
           <img
             src={activeDragData.imageUrl}
             alt={activeDragData.title}
             className="w-full h-full object-cover"
           />
         </div>
       )}
     </DragOverlay>,
     document.body
   )}
 </DndContext>

 DndContextには、3つのプロパティをつけています。
 まず、sensorsはユーザーのどの操作でドラッグの処理を発火させるかを定義しています。以下の通り、今回はマウス操作とタッチ操作に反応するように設定しています。

const sensors = useSensors(
  useSensor(MouseSensor),
  useSensor(TouchSensor)
);

 次に、handleDragStartはドラッグ開始時に実施する処理を定義しています。今回は、後述する処理にドラッグ中のアニメの情報が必要になるため、stateにアニメのタイトルとキービジュアルを保存するようにしています。

  const handleDragStart = (event: DragStartEvent) => {
    setActiveDragData({
      title: event.active.data.current?.title || '',
      imageUrl: event.active.data.current?.imageUrl || '/noImage.png',
    });
  };

 そして、handleDragEndはドラッグ終了時に実施する処理を定義しています。コメントにも書いているのですが、ドラッグ終了時の処理は以下に大別されます。

  • Tier表からアイテムを削除する
  • 候補から新たにアイテムをTier表に加える
  • arrayMoveを使用し、同ランク内のアイテムの並びを入れ替える
  • 異なるランクの間でアイテムを移動する
 const handleDragEnd = (event: DragEndEvent) => {
   // axtive: ドラッグ中のアイテム
   // over: ドロップ先のターゲット (存在しない場合もある)
   const { active, over } = event;
   setActiveDragData(null);

   // ドラッグ中のアイテムのIDとランクを安全に取得
   const activeId = active.id?.toString();
   const fromRank = active.data.current?.rank as TierRank | undefined;
   // ドラッグ中のアイテムがアセットから来ているかどうかを判定
   const isAsset = activeId?.startsWith('asset-');

   // ティア表外 → 削除 (Tier内のアイテムのみ)
   if (!over) {
     if (!fromRank || isAsset) return;
     // ドラッグ中のアイテム以外のアイテムを残して更新
     setTiers(prev => ({
       ...prev,
       [fromRank]: prev[fromRank].filter(i => i.id !== active.id),
     }));
     return;
   }

   // ドロップ先のランクを取得 (Tier内のアイテム or ティア表の空スペース)
   const toRank: TierRank | undefined =
     over.data.current?.rank ??
     (isTierRank(over.id) ? over.id : undefined);

   if (!toRank) return;

   // アセット → Tier
   if (isAsset) {
     // ドラッグ中のアイテムのタイトルと画像URLを取得
     const title = active.data.current?.title as string | undefined;
     const imageUrl = active.data.current?.imageUrl as string | undefined;
     const assetId = active.data.current?.assetId as number | undefined;
     if (!title) return;

     setTiers(prev => ({
       ...prev,
       [toRank]: [
         ...prev[toRank],
         {
           // dnd-kit用のIDを別途生成 (アセットIDは "asset-" プレフィックスが付いているため衝突しない)
           id: crypto.randomUUID(),
           title,
           imageUrl,
           assetId,
           source: 'asset',
         },
       ],
     }));
     return;
   }

   // 同ランク内 並び替え
   if (fromRank === toRank) {
       setTiers(prev => {
         const items = prev[fromRank];
         // ドラッグ中のアイテムとドロップ先のアイテムのインデックスを取得
         const oldIndex = items.findIndex(i => i.id === active.id);
         const newIndex = items.findIndex(i => i.id === over.id);

         // ドラッグ中のアイテムが見つからない場合は更新しない
         if (oldIndex === -1) return prev;

         return {
           ...prev,
           [fromRank]:
             // ドロップ先が空スペースの場合は末尾に移動、そうでない場合は指定位置に移動
             newIndex === -1
               ? arrayMove(items, oldIndex, items.length - 1)
               : arrayMove(items, oldIndex, newIndex),
         };
       });
       return;
     }

     // Tier 間移動
     if (fromRank && fromRank !== toRank) {
       setTiers(prev => {
         // ドラッグ元とドラッグ先のアイテムリストを取得
         const fromItems = prev[fromRank];
         const toItems = prev[toRank];

         // ドラッグ中のアイテムが見つからない場合は更新しない
         if (!Array.isArray(fromItems) || !Array.isArray(toItems)) return prev;

         // ドラッグ中のアイテムを取得
         const item = fromItems.find(i => i.id === active.id);
         if (!item) return prev;

         // ドラッグ中のアイテム以外を残して更新
         // ドロップ先のアイテムは末尾に追加
         return {
           ...prev,
           [fromRank]: fromItems.filter(i => i.id !== active.id),
           [toRank]: [...toItems, item],
         };
       });
     }
   };

DragOverlay

 DndContextを説明するコードの1番下にあった要素です。基本構成では触れていない新出の要素となるので、こちらも説明しようと思います。
 DragOverlayの役割は、ドラッグ中のオブジェクトのスタイルの定義です。handleDragStartでstateに保存していたキービジュアルを使用することで、ドラッグ中のオブジェクトが何のアニメなのかを一目見て分かるようにできます。

   {createPortal(
     <DragOverlay
       dropAnimation={null}
     >
       {activeDragData && (
         <div className="w-24 h-24 rounded shadow-lg overflow-hidden bg-gray-100">
           <img
             src={activeDragData.imageUrl}
             alt={activeDragData.title}
             className="w-full h-full object-cover"
           />
         </div>
       )}
     </DragOverlay>,
     document.body
   )}

image.png

SortableContext

 SortableContextでは、SortableContextを使用しているTierRow関数がTier表における1つのランクを示しており、それをランクの数だけmapで展開しているのがTierBoard関数です。
 これによりTier表全てが並び替え可能なエリアとして定義されます。

const TierRow = ({ rank, items }: { rank: TierRank; items: TierState[TierRank] }) => {
  const { setNodeRef, isOver } = useDroppable({
    id: rank, // ← Tier を識別
  });

  return (
    <div
      ref={setNodeRef}
      className={`
        flex h-24 border border-black
        ${isOver ? 'bg-blue-100' : ''}
      `}
    >
      {/* ラベル */}
      <div
        className={`
          w-20 flex items-center justify-center text-3xl font-bold
          ${tierColorMap[rank]}
        `}
      >
        {rank}
      </div>

      {/* 中身 */}
      <div className="flex-1 bg-yellow-100 flex overflow-x-auto">
        <SortableContext
          items={items.map(item => item.id)}
          strategy={horizontalListSortingStrategy}
        >
          {items.map((item) => (
            <TierItem
              key={item.id}
              id={item.id}
              rank={rank}
              title={item.title}
              imageUrl={item.imageUrl}
            />
          ))}
        </SortableContext>
      </div>
    </div>
  );
};

export const TierBoard = ({
  tiers,
  tierName,
  onNameChange,
}: {
  tiers: TierState;
  tierName: string;
  onNameChange: (value: string) => void;
}) => {
  const TIERS = ['S', 'A', 'B', 'C', 'D'] as const;

  return (
    <section className="flex-2 p-4 overflow-y-auto">
      <div className="mb-4 flex items-center gap-2">
        <input
          type="text"
          value={tierName}
          onChange={(event) => onNameChange(event.target.value)}
          placeholder="ティア表名称"
          className="
            flex-1
            bg-transparent
            border-b border-gray-300
            focus:outline-none
            focus:border-gray-700
            text-lg
            text-black
            placeholder-gray-400
          "
        />
      </div>

      <div className="space-y-2">
        {TIERS.map(tier => (
          <TierRow key={tier} rank={tier} items={tiers[tier]} />
        ))}
      </div>
    </section>
  );
};

useDroppable

 先ほどのTierRow関数内で使用していたHookです。
 ここではTier表のランクをIDとして、ドロップ可能なエリアであることを定義しています。setNodeRefはこのエリアがドロップエリアですという情報をdnd-kitへ渡しており、isOverは現在ドラッグ中のアイテムがエリア上にあるかどうかを表しています。

  const { setNodeRef, isOver } = useDroppable({
    id: rank, // ← Tier を識別
  });

useSortable

 ティア表内での各アイテムに適用されます。
 これにより、各アイテムはティア表内を自由に並び替えできるようになります。

export const TierItem = ({
  id,
  rank,
  title,
  imageUrl,
}: {
  id: string;
  rank: TierRank;
  title?: string;
  imageUrl?: string;
}) => {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
  } = useSortable({
    id,
    data: { rank, title, imageUrl },
  });

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

  return (
    <div
      ref={setNodeRef}
      style={style}
      {...attributes}
      {...listeners}
      className="
        h-1/2 aspect-square
        bg-gray-200 border border-gray-400
        overflow-hidden
        flex items-center justify-center
        text-sm cursor-grab
      "
    >
      {imageUrl ? (
        <img
          src={imageUrl}
          alt={title ?? 'asset'}
          className="w-full h-full object-cover"
        />
      ) : (
        <span className="text-center wrap-break-word px-2">{title || 'IMG'}</span>
      )}
    </div>
  );
};

useDraggable

 またまた基本構成には出てこなかったHookです。
 useDraggableは各アイテムのドラッグ&ドロップを可能にします。これだけ聞くと、useSortableと同じに思えるかもしれませんが、useDraggableは各アイテムのドラッグ&ドロップのみを可能にしているのに対し、useSortableはそれに加えて並び替えも可能にします。
 Tier表を作成する時、最初は候補群から、アイテムをドラッグ&ドロップでTier表に運びますが、ここでは、各アイテムをドラッグ&ドロップできるようにするだけで事足ります(候補群で並び替えをする必要はないので)。そのため、候補群の各アイテムには、useDraggableを適用しています。

const AssetImage: React.FC<{ anime_id: number; title: string; cover_image?: string | null }> = ({ anime_id, title, cover_image }) => {
  const { attributes, listeners, setNodeRef } = useDraggable({
    id: `asset-${anime_id}`,
    data: { assetId: anime_id, title, imageUrl: cover_image || '/noImage.png' },
  });

  return (
    <div
      ref={setNodeRef}
      {...listeners}
      {...attributes}
      className="aspect-square rounded overflow-hidden bg-gray-100 cursor-grab active:cursor-grabbing transition hover:opacity-80"
    >
      <img 
        src={cover_image ? cover_image : '/noImage.png'} 
        alt={title} 
        className="w-full h-full object-cover pointer-events-none"
      />
    </div>
  );
};

ハマったポイント

 私がdnd-kitでTier表を作成する上で最も苦戦したのは、アイテム毎のIDの管理でした。
 Tier表機能の作成におけるIDは以下コードの2パターンで作成しています。1つは候補群のアイテムで、ここでは「asset-」という固定値にDBから取得したアニメのIDを付加しています。もう1つはTier表内のアイテムで、候補群からTier表にアイテムが移動して来た時に、ランダムなUUIDを作成しています(ちなみに、候補群からの移動であるという判定は、既存のIDが「asset-」で始まっているかどうかで判定しています)。
 ここで最初、私は候補群とTier表内で使用するIDを同じものにしていました。しかし、この方法では「候補群に存在するアニメ」と「Tier表に配置されたアニメ」を区別することができず、上手く動作させることができませんでした。
 例えば、アニメIDが123のアニメを候補群からTier表へ移動した場合、候補群にも123、Tier表にも123という同じIDが存在することになります。dnd-kitでは、ドラッグするアイテムをidによって識別しています。そのため、同じIDを持つアイテムが複数存在すると、「現在ドラッグしているアイテムはどれなのか」「どのアイテムを移動させるのか」といった判定が正しく行えなくなります。
 よって、候補群とTier表のアイテムでIDを分離させることが必要でした。

const AssetImage: React.FC<{ anime_id: number; title: string; cover_image?: string | null }> = ({ anime_id, title, cover_image }) => {
 const { attributes, listeners, setNodeRef } = useDraggable({
   id: `asset-${anime_id}`,
   data: { assetId: anime_id, title, imageUrl: cover_image || '/noImage.png' },
 });

 // 省略
};

// ドラッグ中のアイテムがアセットから来ているかどうかを判定
  const isAsset = activeId?.startsWith('asset-');

  // 省略

  // アセット → Tier
  if (isAsset) {
    // ドラッグ中のアイテムのタイトルと画像URLを取得
    const title = active.data.current?.title as string | undefined;
    const imageUrl = active.data.current?.imageUrl as string | undefined;
    const assetId = active.data.current?.assetId as number | undefined;
    if (!title) return;

    setTiers(prev => ({
      ...prev,
      [toRank]: [
        ...prev[toRank],
        {
          // dnd-kit用のIDを別途生成 (アセットIDは "asset-" プレフィックスが付いているため衝突しない)
          id: crypto.randomUUID(),
          title,
          imageUrl,
          assetId,
          source: 'asset',
        },
      ],
    }));
    return;
  }

さいごに

 最後まで閲覧いただきありがとうございました。
 今回dnd-kitを使ってみた感想ですが、最初はアプリケーションにおいてドラッグ&ドロップできるUIがどのように作成されているのか全く分かっていなかった私が実際にTier表機能を形にできて驚いています。開発当初はハマったポイントとで述べたような問題が発生し、中々理解が追い付かない中でがむしゃらにトライ&エラーで進めていたのですが、改めてこうして纏めてみると、機能毎に必要な役割が明確に分けられており、そこさえしっかりと掴めていれば、問題なく進められそうだと感じました。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?