🦒

React Hooksのみでドラッグ&ドロップの並び替えを実装する

2021/04/01に公開
2

ドラッグ&ドロップのソートの実際の挙動

この記事について

上記のようなドラッグ&ドロップを使った並び替えの処理を自作したは良いものの、使うことが無くなってしまったので、供養の意味を込めて、その時に得た知見をこの記事で共有したいと思います 💪

実装する条件

この記事で実装する処理は以下の条件のもと実装してきます。

  1. サードパーティ製のライブラリを使用しない
  2. React Hooks を使って実装する
  3. 並び替えするときにアニメーションさせる
  4. 簡単に扱えるようにする!

アニメーションは CSS を使って行いますが、今回は簡略化の為に CSS ファイルは扱わずにインライン CSSを用いる事とします。

どのように使えるか考える

では早速、「 実装していくぞー 💪 」と行きたい所ですが、今回のような汎用的な処理を自作する時は、「 どういう感じで使いたいか? 」という所から考えた方が、色々とやりやすいと思っています。なので、今回はそこから考えてみましょう 👨‍💻👩‍💻

また、この節の大事なポイントとして、useDnDSort()は既に実装していると仮定しながら書いてきます。何故なら、現時点で重要なポイントは「 どういう感じで使いたいか? 」であって、具体的なロジックでは無いからです。なので、この節のソースコードにはuseDnDSort()の実装は含まないようにしていますので、注意してください。

※ 具体的な実装は後でちゃんと実装します。

1. Hooks の名前を決める

まず React Hooks を用いるので、必然的に React Cutom Hooks を作ることになります。今回は、useDnDSortと言う名前の Custom Hooks を作る事にしましょう。

useDnDSortの使い方
const AnyComponent = () => {
  useDnDSort();
}

2. 入力を決める

次に、並び替える要素が必要ですね。
汎用性を高めたいので、引数で受け取る形にしましょう。
また今回は画像を表示するため、引数に渡す値は画像の URL を含んだ配列にします 🌅

useDnDSortの使い方
// 並び替えしたい画像URLの配列
const imageList: string[] = [
  "https://...",
  "https://...",
  "https://...",
  // ...
];

const AnyComponent = () => {
  useDnDSort(imageList);  // 引数で並び替えする配列を受け取るようにする
}

3. 出力を決める

入力を決めたので、出力も決めちゃいましょう 🎳

出力結果は、ソート結果を含んだ配列を返すようにします。そうする事で、その配列を使って要素を作成することが出来るようになりますので、利便性が上がります。

それを踏まえて、ソースコードは以下のようになります 👇

useDnDの使い方
// 並び替えしたい画像URLの配列
const imageList: string[] = [
  "https://...",
  "https://...",
  "https://...",
  // ...
];

const AnyComponent = () => {
  // ソート結果を受け取る
  const results = useDnDSort(imageList);

  return (
    <div>

      {/* 配列の要素を表示する */}
      {results.map((image) => (
        <div>
	  <img src={image} alt="ソート可能な画像" />
	</div>
      ))}

    </div>
  );
}

4. ドラッグ&ドロップに対応させる

次に要素をドラッグ&ドロップに対応させるようにします。

要素をドラッグ&ドロップするには、それに対応するイベント(on ~などの関数)を設定する必要がありますが、それを別々に設定すると面倒なので、useDnDSort()の結果に対応するイベントの関数を含めるようにします。

また、React では配列を表示する時にkey propsを設定しないと警告文が発生してしまうので、そうならないようにkey propsに設定する文字列も配列の結果に含めたいと思います。

上記を踏まえると、ソースコードは以下のようになります 👇

useDnDの使い方
// 並び替えしたい画像URLの配列
const imageList: string[] = [
  "https://...",
  "https://...",
  "https://...",
  // ...
];

// useDnDSort()の結果の型
interface Result {
  // key propsに設定する文字列
  key: string;

  // 配列内の画像URL文字列
  value: string;

  // ドラッグ&ドロップ処理で使うイベント関数を返す関数
  events: {
    ref: (value: any) => void;
    onMouseDown: (event: any) => void;
  }
}

const AnyComponent = () => {
  // ソート結果とイベントを含んだ配列を受け取る
  const results: Result[] = useDnDSort(imageList);

  return (
    <div>

      {/* 配列の要素を表示する */}
      {results.map((item) => (
        <div key={item.key}>
	  <img
	    src={item.value}
	    alt="ソート可能な画像"
	    { ...item.events }
	  />
	</div>
      ))}

    </div>
  );
}

今回のドラッグ&ドロップ処理では ref()onMouseDown() を使うため、結果配列の要素にそれらをまとめたeventsを含めるようにしています。

こうする事によって、{ ...item.events }のように書けるため、仮に設定したいイベントが増えたとしても、useDnDSort()を使用しているソースコードを変更する必要が無くなります。

5. 使い方をまとめる

ここまでのソースコードをまとめると、以下のようになると思います。

useDnDの使い方
const imageList: string[] = [/* -- 省略 -- */];

const AnyComponent = () => {
  const results = useDnDSort(imageList);

  return (
    <div>
      {results.map((item) => (
        <div key={item.key}>
	  <img src={item.value} alt="ソート可能な画像"  { ...item.events } />
	</div>
      ))}
    </div>
  );
}

以降の節では、上記のソースコードが動くようにuseDnDSort()を実装していきます 💪

並び替える要素を用意する

これからドラッグ&ドロップの並び替えを実装していきますが、並び替えるモノが無ければやりにくいと思いますので、先ずは記事の冒頭で表示していた gif 画像のようなデザインを表示するための<SortSampleApp />を実装します。

※ 単純な React のソースコードですので、詳しい解説は省かせてもらいます

./index.tsx
import React from "react";
import { render } from "react-dom";

type Style<T extends HTMLElement> = React.HTMLAttributes<T>["style"]

const bodyStyle: Style<HTMLDivElement> = {
  height: "100vh",
  display: "flex",
  overflow: "hidden",
  alignItems: "center",
  justifyContent: "center"
};

const containerStyle: Style<HTMLDivElement> = {
  display: "flex",
  flexWrap: "wrap",
  justifyContent: "space-between",
  width: "100%",
  maxWidth: "350px",
  maxHeight: "500px"
};

const imageCardStyle: Style<HTMLDivElement> = {
  cursor: "grab",
  userSelect: "none",
  width: "100px",
  height: "130px",
  overflow: "hidden",
  borderRadius: "5px",
  margin: 3
};

const imageStyle: Style<HTMLImageElement> = {
  pointerEvents: "none",
  objectFit: "cover",
  width: "100%",
  height: "100%"
};

// 並び替えしたい画像URLの配列
const imageList: string[] = [
  "https://...",
  "https://...",
  "https://...",
  /* ... */
];

// ドラッグ&ドロップ並び替えサンプルのコンポーネント
const SortSampleApp = () => {
  return (
    <div style={bodyStyle}>
      <div style={containerStyle}>
        {imageList.map((item: string) => (
          <div key={item} style={imageCardStyle}>
            <img src={item} alt="ソート可能な画像" style={imageStyle} />
          </div>
        ))}
      </div>
    </div>
  );
}

let rootElement = document.getElementById("root");

// rootElementが無ければ作成してdocument.bodyに追加する
if (!rootElement) {
  rootElement = document.createElement("div");
  rootElement.id = "root";
  document.body.appendChild(rootElement);
}

// SortSampleAppコンポーネントを表示する
render(<SortSampleApp />, rootElement);

上記のソースコードを実行して、画像がタイル状に表示されていれば OK👌 です。

useDnDSort をインポートして使う

次に、上記のソースコードにuseDnDSort()を実装します。useDnDSortをインポートして、<SortSampleApp />内で実行するように修正しましょう 👇

./index.tsx
import React from "react";
import { render } from "react-dom";

// useDnDSortをインポート。これから実装するのでファイルはまだ無い事に注意!
import { useDnDSort } from "./useDnDSort";

/* -- 省略 -- */

// ドラッグ&ドロップ並び替えサンプルのコンポーネント
const SortSampleApp = () => {
  // useDnDSort()を使って並び替え処理を実装する
  const results = useDnDSort(imageList);

  return (
    <div style={bodyStyle}>
      <div style={containerStyle}>

        {/* useDnDSort()の返り値を使って画像を表示する */}
        {results.map((item) => (
          <div key={item.key} style={imageCardStyle} {...item.events}>
            <img src={item.value} alt="ソート可能な画像" style={imageStyle} />
          </div>
        ))}

      </div>
    </div>
  );
}

/* -- 省略 -- */

現時点では、上記のソースコードだとエラーが発生してしまいますが、これ以降上記のソースコードは修正する必要が無いので、そのままで大丈夫です。

今度はエラーが発生しないようにuseDnDSort()を実装して行きましょう 🧉

useDnDSort()を実装する

ここからは実際にuseDnDSort()の中身を実装していきます 🧀

注意として、useDnDSort()の実装は別ファイルで実装している事を想定して書いていきます。もし、ワンファイルで実装している場合は適時対応してください 🙏

返り値を実装する

先ずは、型エラーを無くすために返り値を返すようにします。
前項の内容から返り値の型は定義出来ているので、それを元に実装すると以下のようになります 👇

./useDnDSort.ts
import React, { useRef, useState } from "react";

// 返り値の型
interface DnDSortResult<T> {
  key: string;
  value: T;
  events: {
    ref: (element: HTMLElement | null) => void;
    onMouseDown: (event: React.MouseEvent<HTMLElement>) => void;
  }
}

export const useDnDSort = <T>(defaultItems: T[]): DnDSortResult<T>[] => {
  // 描画内容と紐づいているのでuseStateで管理する
  const [items, setItems] = useState(defaultItems);

  return items.map((value: T): DnDSortResult<T> => {
    return {
      value,

      key: Math.random().toString(16),

      events: {
        ref: () => void 0,

        onMouseDown: () => void 0,
      }
    }
  })
}

上記のソースコードで、useDnDSort()を使ってみても型エラーが出ていなければ OK ですが、まだドラッグ&ドロップは出来ない事に注意してください。

次の節で、ドラッグ&ドロップ処理を実装して行きましょう 👉

ドラッグ&ドロップ処理を実装する

フロントエンドでのドラッグというと、ドラッグ&ドロップ APIなどのネイティブな API がありますが、今回はそれらの API を使わずに、マウスイベントと CSS を用いてシンプルに実装していきます。

具体的には、以下のようなロジックを実装していきます。

  1. ドラッグする要素のonMouseDown()で、初期化処理を行う( ドラッグ開始 )
  2. window.mousemove()で、ドラッグ要素を CSS の transform 使って移動させる( ドラッグ中 )
  3. window.mouseup()で、ドラッグ要素の座標を元に戻す( ドロップとドラッグ終了 )

上記の流れを踏まえて、先ずは処理に必要なローカル変数の定義から実装してきましょう 🍿

ローカル変数を定義する

ドラッグ&ドロップ処理に必要な情報を保持するためのローカル変数( 状態 )を定義していきます。

React Hooks の場合、useState()で状態を保持するのが定石ですが、状態を更新するたびに描画更新が走ってしまうため、今回のようにイベントを沢山扱う処理で使ってしまうと描画更新が大量に走ってしまい、重くなってしまいます。

そのため、今回の実装ではuseRef()を使って実装する事で、無駄な描画更新を減らすようにします。

ソースコードに、ローカル変数部分を追加すると以下のようになります 👇

ドラッグ処理に必要な変数を定義
import React, { useRef, useState } from "react";

// 座標の型
interface Position {
  x: number;
  y: number;
}

// ドラッグ&ドロップ要素の情報をまとめた型
interface DnDItem<T> {
  value: T;              // useDnDSort()の引数に渡された配列の要素の値
  key: string;           // 要素と紐づいた一意な文字列
  position: Position;    // 要素の座標
  element: HTMLElement;  // DOM情報
}

// useRef()で保持するデータの型
interface DnDRef<T> {
  keys: Map<T, string>;            // 要素に紐づいたkey文字列を管理するMap
  dndItems: DnDItem<T>[];          // 並び替える全ての要素を保持するための配列
  canCheckHovered: boolean;        // 重なり判定ができるかのフラグ
  pointerPosition: Position;       // マウスポインターの座標
  dragElement: DnDItem<T> | null;  // ドラッグしてる要素
}

// 返り値の型
interface DnDSortResult<T> {
  /* -- 省略 -- */
}

export const useDnDSort = <T>(defaultItems: T[]): DnDSortResult<T>[] => {
  const [items, setItems] = useState(defaultItems);

  // 状態をrefで管理する
  const state = useRef<DnDRef<T>>({
    dndItems: [],
    keys: new Map(),
    dragElement: null,
    canCheckHovered: true,
    pointerPosition: { x: 0, y: 0 }
  }).current;

  return items.map(
    (value: T): DnDSortResult<T> => {
      // keyが無ければ新しく作り、あれば既存のkey文字列を返す
      const key = state.keys.get(value) || Math.random().toString(16);

      // 生成したkey文字列を保存
      state.keys.set(value, key);

      return {
        value,

        key, // ref.keys内の値を参照するように修正

        events: {
          ref: () => void 0,
          onMouseDown: () => void 0,
        }
      };
    }
  );
}

上記の実装で特にエラーなどが出てなければ OK👌 です。

注意点としては、useRef()の返り値は、引数で渡した値が current の中に入っているので、

上記の実装より抜粋
// 状態をrefで管理する
const state = useRef(...).current;

としている事に注意してください。.current が抜けていると型エラーが発生します。

問題が無ければ、次にevents.onMouseDown()を実装していきましょう 🍧

onMouseDown()を実装する

ドラッグを開始するための処理を書いていきます。

ドラッグ可能な要素にonMouseDown()を設定して、そのイベントが発火した時にuseRef()で管理している変数に必要な情報を入れて行きます。

またこの時、windowmousemovemouseupイベントを設定しますが、設定する関数はまだ実装してませんので、エラーが発生する事に注意してください!

ソースコードのeventsの部分の以下のように修正しましょう 👇

hoge
return items.map(
  (value: T): DnDSortResult<T> => {
    /* -- 省略 -- */

    return {
      /* -- 省略 -- */

      events: {
        ref: () => void 0,

        onMouseDown: (event: React.MouseEvent<HTMLElement>) => {
          // ドラッグする要素(DOM)
          const element = event.currentTarget;

          // マウスポインターの座標を保持しておく
          state.pointerPosition.x = event.clientX;
          state.pointerPosition.y = event.clientY;

          // ドラッグしている要素のスタイルを上書き
          element.style.transition = "";     // アニメーションを無効にする
          element.style.cursor = "grabbing"; // カーソルのデザインを変更

          // 要素の座標を取得
          const { left: x, top: y } = element.getBoundingClientRect();
          const position: Position = { x, y };

          // ドラッグする要素を保持しておく
          state.dragElement = { key, value, element, position };

          // mousemove, mouseupイベントをwindowに登録する
          window.addEventListener("mouseup", onMouseUp);
          window.addEventListener("mousemove", onMouseMove);
        }
      }
    };
  }
);

上記の実装では、onMouseUp()onMouseMove()windowに設定していますが、なぜwindowに設定するのかと言うと、ドラッグしている間は、マウスポインターは画面内を縦横無尽に動くことが想定されるので、マウスポインターが要素外に出てしまう事が想定されます。

そのため、マウスポインターの座標をずっと監視し続けないといけないmousemovemouseupの二つのイベントをドラッグ要素に設定してしまうと、正しくマウスポインターを監視し続ける事が出来なくなってしまいます。

なので、画面全体を監視できるwindowにイベント設定する事で確実にマウスポインターの動きを監視するようにしています。

次はドラッグ要素を動かすためにonMouseup()を実装してきましょう 🍄

onMouseMove()を実装する

今度は、ドラッグ中の処理を実装していきます。ソースコードは以下のようになります 👇

onMouseMoveを実装
/* -- 省略 -- */

export const useDnDSort = <T>(defaultItems: T[]): DnDSortResult<T>[] => {
  const [items, setItems] = useState(defaultItems);

  // 状態をrefで管理する
  const state = useRef<DnDRef<T>>(/* -- 省略 -- */).current;

  // ドラッグ中の処理
  const onMouseMove = (event: MouseEvent) => {
    const { clientX, clientY } = event;
    const { dragElement, pointerPosition } = state;

    // ドラッグして無ければ何もしない
    if (!dragElement) return;

    // マウスポインターの移動量を計算
    const x = clientX - pointerPosition.x;
    const y = clientY - pointerPosition.y;

    const dragStyle = dragElement.element.style;

    // ドラッグ要素の座標とスタイルを更新
    dragStyle.zIndex = "100";
    dragStyle.cursor = "grabbing";
    dragStyle.transform = `translate(${x}px,${y}px)`;
  };

  return items.map(/* -- 省略 -- */);
}

ここでのポイントとして、translate(${x}px,${y}px)に指定する x, y の値はドラッグしている要素の元々の位置からの相対座標になります。

そのため、event.clientXevent.clientYの値をそのまま入れてしまうとドラッグ要素がずれてしまうので、マウスポインターの移動量を計算して、その結果を translate に入れてあげるようにしています。

次はドラッグ終了処理であるonMouseUp()を実装していきましょう 🍡

onMouseUp()を実装する

ドロップとドラッグ終了処理を実装していきます。

やる事はドラッグ開始処理とは逆で、状態の初期化やwindowに設定しているイベントの削除などを行います。

以下のようにソースコード修正しましょう 👇

./useDnDSort.ts
/* -- 省略 -- */

export const useDnDSort = <T>(defaultItems: T[]): DnDSortResult<T>[] => {
  /* -- 省略 -- */

  // ドラッグ中の処理
  const onMouseMove = (event: MouseEvent) => { /* -- 省略 -- */ }

  // ドラッグが終了した時の処理
  const onMouseUp = (event: MouseEvent) => {
    const { dragElement } = state;

    // ドラッグしていなかったら何もしない
    if (!dragElement) return;

    const dragStyle = dragElement.element.style;

    // ドラッグしてる要素に適用していたCSSを削除
    dragStyle.zIndex = "";
    dragStyle.cursor = "";
    dragStyle.transform = "";

    // ドラッグしている要素をstateから削除
    state.dragElement = null;

    // windowに登録していたイベントを削除
    window.removeEventListener("mouseup", onMouseUp);
    window.removeEventListener("mousemove", onMouseMove);
  };

  return items.map(/* -- 省略 -- */);
}

ここまでの実装でエラーが発生して無ければ OK👌 です。

これにてドラッグ&ドロップ処理が実装出来ました 🏋️‍♀️🏋️‍♂️

挙動の確認は、以下の gif 画像を参考にして下さい 🤸‍♀️🤸‍♂️

ドラッグ&ドロップの挙動の確認

実装したドラッグ&ドロップの動作確認

上記の実装で、gif 画像のように要素をドラッグできるようになっていれば OK です 👌

重なり判定を実装する

次はソート処理をするために必要な要素同士の重なり判定を実装してきます ⛩

isHover()を実装する

これから要素が重なったどうかを判定するisHover()を実装してきますが、要素同士の重なりと言っても、実際にはマウスポインターが要素に重なったかを検出します。

別に要素同士の重なりでもいいんですが、実装が少し長くなってしまう為、この記事では短く書けるマウスポインターを使った判定で実装しています。

※ 挙動的にはあまり違いは無いと思いますので、そこまで気にしなくて大丈夫です。

以下のようにファイルを修正しましょう 👇

./useDnDSort.ts
import React, { useRef, useState } from "react";

/**
 * @description マウスポインターが要素と被っているか判定します
 */
const isHover = (event: MouseEvent, element: HTMLElement): boolean => {
  // マウスポインターの座標を取得
  const clientX = event.clientX;
  const clientY = event.clientY;

  // 重なりを判定する要素のサイズと座標を取得
  const rect = element.getBoundingClientRect();

  // マウスポインターが要素と重なっているかを判定する
  return (
    clientY < rect.bottom &&
    clientY > rect.top &&
    clientX < rect.right &&
    clientX > rect.left
  );
};

/* -- 省略 -- */

export const useDnDSort = <T>(defaultItems: T[]): DnDSortResult<T>[] => {
  /* -- 省略 -- */
}

isHover()の実装が出来たら、次はonMouseMove()内で実行するようにしましょう 🏎

onMouseMove()内で重なりを判定する

要素をドラッグしている間に要素とマウスポインターが重なっているかを判定して、重なっていれば、ログ画面に"Hello World!"を表示する処理を実装してきます。

onMouseMouse()を以下のように修正しましょう 👇

./useDnDSort.ts
/* -- 省略 -- */

export const useDnDSort = <T>(defaultItems: T[]): DnDSortResult<T>[] => {
  /* -- 省略 -- */

  // ドラッグ中の処理
  const onMouseMove = (event: MouseEvent) => {
    const { clientX, clientY } = event;
    const { dndItems, dragElement, pointerPosition } = state; // dndItemsを追加

    /* -- 省略 -- */

    // まだ確認できない場合は処理を終了する
    if (!state.canCheckHovered) return;

    // 確認できないようにする
    state.canCheckHovered = false;

    // 300ms後に確認できるようにする
    setTimeout(() => (state.canCheckHovered = true), 300);

    // ドラッグしている要素の配列の位置を取得
    const dragIndex = dndItems.findIndex(({ key }) => key === dragElement.key);

    // ホバーされている要素の配列の位置を取得
    const hoveredIndex = dndItems.findIndex(
      ({ element }, index) => index !== dragIndex && isHover(event, element)
    );

    if (hoveredIndex !== -1) {
      // ホバーしていればコンソール画面に"Hello World!"を表示
      console.log("Hello World!");
    }
  }

  /* -- 省略 -- */

  return items.map(/* -- 省略 -- */);
}

ここでのポイントは二つあります。

一つ目のポイントは、チェックしてから 300ms 経たないとチェックできないようにしています。

こうする事によって、処理を軽くすることが出来ますし、入れ替え処理を行う時に必要以上に要素の入れ替えが起こる事を防ぐ目的もあります。

二つ目のポイントは、dndItems.findIndex()で重なっている要素の添え字を取ってくる時、ドラッグしている要素は除外するようにしています。

何故なら、マウスポインターとドラッグしている要素は必ず重なっているので、除外せずに判定すると、添え字の値は必ずドラッグしている要素の添え字になります。

なので、重なり判定をする時はドラッグしていない要素だけを判定する必要があります。

さて、重なり判定を実装しましたが、動かしてみるとログ画面に何も表示されません。
それもそのはずで、要素を格納しているはずのdndItemsに、要素を追加する処理を書いていないので、dndItemsはずっと空配列のままになっています。

なので、次はdndItemsに要素を追加する処理を書いていきます 🛴

events.ref()を実装する

dndItemsに要素を追加するには実際の DOM 情報が必要になりますが、React の Ref APIを使うと DOM 情報を取得することが出来ますので、events.ref()にその処理を実装していきます。

events.ref()を以下のようにソースコードを修正しましょう 👇

items.map((value: T): DnDSortResult<T> => {
  /* -- 省略 -- */

  return {
    /* -- 省略 -- */

    events: {
      ref: (element: HTMLElement | null) => {
        if (!element) return;

        const { dndItems } = state;

        // 位置をリセットする
        element.style.transform = "";

        // 要素の位置を取得
        const { left: x, top: y } = element.getBoundingClientRect();
        const position: Position = { x, y };

        const itemIndex = dndItems.findIndex((item) => item.key === key);

        // 要素が無ければ新しく追加して処理を終わる
        if (itemIndex === -1) {
          return dndItems.push({ key, value, element, position });
        }

        // 要素を更新する
        state.dndItems[itemIndex] = { key, value, element, position };
      },

      onMouseDown: (event: React.MouseEvent<HTMLElement>) => {
        /* -- 省略 -- */
      },
    },
  };
});

ここまでで、重なり判定の実装は完了です 🤡

次の節では、実際に要素を並び替える処理を実装してきます 🚀

実際の挙動は以下の gif 画像を参考にして下さい 🚁

重なり判定の挙動の確認

重なり判定の動作確認

gif 画像のようにログ画面に"Hello World"が表示されていれば OK👌 です。

並び替え処理を実装する

次は実際に要素を並び替える処理を実装していきます。

重なり判定の実装は既にしてあるので、そこに並び替えのロジックを実装するだけです。

具体的なソースコードは以下のようになります 👇

./useDnDSort.ts
/* -- 省略 -- */

export const useDnDSort = <T>(defaultItems: T[]): DnDSortResult<T>[] => {
  /* -- 省略 -- */

  // ドラッグ中の処理
  const onMouseMove = (event: React.MouseEvent<HTMLElement>) => {
    /* -- 省略 -- */

    // ホバーされている要素があれば、ドラッグしている要素と入れ替える
    if (hoveredIndex !== -1) {
      // カーソルの位置を更新
      state.pointerPosition.x = clientX;
      state.pointerPosition.y = clientY;

      // 要素を入れ替える
      dndItems.splice(dragIndex, 1);
      dndItems.splice(hoveredIndex, 0, dragElement);

      const { left: x, top: y } = dragElement.element.getBoundingClientRect();

      // ドラッグ要素の座標を更新
      dragElement.position = { x, y };

      // 再描画する
      setItems(dndItems.map((v) => v.value));
    }
  }

  /* -- 省略 -- */

  return items.map(/* -- 省略 -- */);
}

上記の実装を動かしてみて、要素の並び替えが出来ていれば大丈夫です 🎙

しかし、並び替えするたびにドラッグしている要素がズレて、快適に並び替えが出来ません。なので、このズレを無くす処理をevents.ref()に記述していきます。

events.ref()を以下のように修正しましょう 👇

./useDnDSort.ts
return items.map(
  (value: T): DnDSortResult<T> => {
    /* -- 省略 -- */

    return {
      /* -- 省略 -- */

      events: {
        ref: (element: HTMLElement | null) => {
          if (!element) return;

          // dragElementとpointerPositionを追加
          const { dndItems, dragElement, pointerPosition } = state;

	  /* -- 省略 -- */

          if (dragElement?.key === key) {
            // ドラッグ要素のズレを計算する
            const dragX = dragElement.position.x - position.x;
            const dragY = dragElement.position.y - position.y;

            // 入れ替え時のズレを無くす
            element.style.transform = `translate(${dragX}px,${dragY}px)`;

            // マウスポインターの位置も再計算してズレを無くす
            pointerPosition.x -= dragX;
            pointerPosition.y -= dragY;
          }

          // 要素を更新する
          state.dndItems[itemIndex] = { key, value, element, position };
        },

        onMouseDown: (event: React.MouseEvent<HTMLElement>) => {
	  /* -- 省略 -- */
        }
      }
    };
  }
);

上記の実装で、要素が入れ替わってもドラッグ要素がズレていなければ OK👌 です。

これにて並び替え処理は実装出来たので、後はアニメーションを実装していきましょう 🎧

並び替えの実際の挙動は以下の gif を参考にして下さい 📢

並び替え処理の挙動の確認

実装した並び替え処理の動作確認

gif 画像のように並び替えが出来ていれば大丈夫です!

アニメーションを実装する

ここまでの実装で、ドラッグ&ドロップによる並び替えは実装出来ましたが、要素が移動する際にカクついたような動きになっています。なので、そこを CSS のアニメーションを使って滑らかに移動するようにします 🎮

仕組み

仕組みとしては至って単純です。
要素が移動した際に、CSS のtranslate()を使って前回居た位置に要素を移動させ、そこからアニメーションをさせながら本来の位置に戻すことで、移動したように見せることが出来ます。

言葉だけだと分かりづらいと思いますので、以下のサンプルコードを実際に動かしてみると分かると思います。

遅延させてみると、背景だけがすぐに移動して、Box は遅れて移動しているのが分かります。これを遅延させずにやると、ゆっくり要素が移動しているように見えるという事です。

仕組みが理解出たら、上記のアニメーションを要素が並び変わった時に実行するようにしましょう 👾

アニメーションの実装

events.ref()内でdndItemsを更新する処理を書いていますが、そこにアニメーションをする処理を書いていきます。

以下のように修正しましょう 👇

return items.map((value: T): DnDSortResult<T> => {
  /* -- 省略 -- */

  return {
    /* -- 省略 -- */

    events: {
      ref: (element: HTMLElement | null) => {
        /* -- 省略 -- */

        // ドラッグ要素以外の要素をアニメーションさせながら移動させる
        if (dragElement?.key !== key) {
          const item = dndItems[itemIndex];

          // 前回の座標を計算
          const x = item.position.x - position.x;
          const y = item.position.y - position.y;

          // 要素を前回の位置に留めておく
          element.style.transition = "";
          element.style.transform = `translate(${x}px,${y}px)`;

          // 一フレーム後に要素をアニメーションさせながら元に位置に戻す
          requestAnimationFrame(() => {
            element.style.transform = "";
            element.style.transition = "all 300ms";
          });
        }

        // 要素を更新する
        state.dndItems[itemIndex] = { key, value, element, position };
      },

      onMouseDown: (event: React.MouseEvent<HTMLElement>) => {
        /* -- 省略 -- */
      },
    },
  };
});

これでアニメーションの実装は終了です 🎉

完成!

ここまでの実装してみて、ドラッグ&ドロップで並び替え出来ていたら無事完成です 🎊

以下に今回作ったuseDnDSort()のサンプルとコード全体を載せておきますので、宜しければご確認下さい。

お疲れ様でした 🙌

useDnDSort()の全体のソースコード
./useDnDSort.ts
import React, { useRef, useState } from "react";

/**
 * @description マウスポインターが要素と被っているか判定します
 */
const isHover = (event: MouseEvent, element: HTMLElement): boolean => {
  // マウスポインターの座標を取得
  const clientX = event.clientX;
  const clientY = event.clientY;

  // 重なりを判定する要素のサイズと座標を取得
  const rect = element.getBoundingClientRect();

  // マウスポインターが要素と重なっているかを判定する
  return (
    clientY < rect.bottom &&
    clientY > rect.top &&
    clientX < rect.right &&
    clientX > rect.left
  );
};

// 座標の型
interface Position {
  x: number;
  y: number;
}

// ドラッグ&ドロップ要素の情報をまとめた型
interface DnDItem<T> {
  value: T; // useDnDSort()の引数に渡された配列の要素の値
  key: string; // 要素と紐づいた一意な文字列
  position: Position; // 要素の座標
  element: HTMLElement; // DOM情報
}

// useRef()で保持するデータの型
interface DnDRef<T> {
  keys: Map<T, string>; // 要素に紐づいたkey文字列を管理するMap
  dndItems: DnDItem<T>[]; // 並び替える全ての要素を保持するための配列
  canCheckHovered: boolean; // 重なり判定ができるかのフラグ
  pointerPosition: Position; // マウスポインターの座標
  dragElement: DnDItem<T> | null; // ドラッグしてる要素
}

// 返り値の型
interface DnDSortResult<T> {
  key: string;
  value: T;
  events: {
    ref: (element: HTMLElement | null) => void;
    onMouseDown: (event: React.MouseEvent<HTMLElement>) => void;
  };
}

export const useDnDSort = <T>(defaultItems: T[]): DnDSortResult<T>[] => {
  // 描画内容と紐づいているのでuseStateで管理する
  const [items, setItems] = useState(defaultItems);

  // 状態をrefで管理する
  const state = useRef<DnDRef<T>>({
    dndItems: [],
    keys: new Map(),
    dragElement: null,
    canCheckHovered: true,
    pointerPosition: { x: 0, y: 0 }
  }).current;

  // ドラッグ中の処理
  const onMouseMove = (event: MouseEvent) => {
    const { clientX, clientY } = event;
    const { dndItems, dragElement, pointerPosition } = state;

    // ドラッグして無ければ何もしない
    if (!dragElement) return;

    // マウスポインターの移動量を計算
    const x = clientX - pointerPosition.x;
    const y = clientY - pointerPosition.y;

    const dragStyle = dragElement.element.style;

    // ドラッグ要素の座標とスタイルを更新
    dragStyle.zIndex = "100";
    dragStyle.cursor = "grabbing";
    dragStyle.transform = `translate(${x}px,${y}px)`;

    // まだ確認できない場合は処理を終了する
    if (!state.canCheckHovered) return;

    // 確認できないようにする
    state.canCheckHovered = false;

    // 300ms後に確認できるようにする
    setTimeout(() => (state.canCheckHovered = true), 300);

    // ドラッグしている要素の配列の位置を取得
    const dragIndex = dndItems.findIndex(({ key }) => key === dragElement.key);

    // ホバーされている要素の配列の位置を取得
    const hoveredIndex = dndItems.findIndex(
      ({ element }, index) => index !== dragIndex && isHover(event, element)
    );

    // ホバーされている要素があれば、ドラッグしている要素と入れ替える
    if (hoveredIndex !== -1) {
      // カーソルの位置を更新
      state.pointerPosition.x = clientX;
      state.pointerPosition.y = clientY;

      // 要素を入れ替える
      dndItems.splice(dragIndex, 1);
      dndItems.splice(hoveredIndex, 0, dragElement);

      const { left: x, top: y } = dragElement.element.getBoundingClientRect();

      // ドラッグ要素の座標を更新
      dragElement.position = { x, y };

      // 再描画する
      setItems(dndItems.map((v) => v.value));
    }
  };

  // ドラッグが終了した時の処理
  const onMouseUp = (event: MouseEvent) => {
    const { dragElement } = state;

    // ドラッグしていなかったら何もしない
    if (!dragElement) return;

    const dragStyle = dragElement.element.style;

    // ドラッグしてる要素に適用していたCSSを削除
    dragStyle.zIndex = "";
    dragStyle.cursor = "";
    dragStyle.transform = "";

    // ドラッグしている要素をstateから削除
    state.dragElement = null;

    // windowに登録していたイベントを削除
    window.removeEventListener("mouseup", onMouseUp);
    window.removeEventListener("mousemove", onMouseMove);
  };

  return items.map(
    (value: T): DnDSortResult<T> => {
      // keyが無ければ新しく作り、あれば既存のkey文字列を返す
      const key = state.keys.get(value) || Math.random().toString(16);

      // 生成したkey文字列を保存
      state.keys.set(value, key);

      return {
        value,

        key,

        events: {
          ref: (element: HTMLElement | null) => {
            if (!element) return;

            const { dndItems, dragElement, pointerPosition } = state;

            // 位置をリセットする
            element.style.transform = "";

            // 要素の位置を取得
            const { left: x, top: y } = element.getBoundingClientRect();
            const position: Position = { x, y };

            const itemIndex = dndItems.findIndex((item) => item.key === key);

            // 要素が無ければ新しく追加して処理を終わる
            if (itemIndex === -1) {
              return dndItems.push({ key, value, element, position });
            }

            // ドラッグ要素の時は、ズレを修正する
            if (dragElement?.key === key) {
              // ドラッグ要素のズレを計算する
              const dragX = dragElement.position.x - position.x;
              const dragY = dragElement.position.y - position.y;

              // 入れ替え時のズレを無くす
              element.style.transform = `translate(${dragX}px,${dragY}px)`;

              // マウスポインターの位置も再計算してズレを無くす
              pointerPosition.x -= dragX;
              pointerPosition.y -= dragY;
            }

            // ドラッグ要素以外の要素をアニメーションさせながら移動させる
            if (dragElement?.key !== key) {
              const item = dndItems[itemIndex];

              // 前回の座標を計算
              const x = item.position.x - position.x;
              const y = item.position.y - position.y;

              // 要素を前回の位置に留めておく
              element.style.transition = "";
              element.style.transform = `translate(${x}px,${y}px)`;

              // 一フレーム後に要素をアニメーションさせながら元に位置に戻す
              requestAnimationFrame(() => {
                element.style.transform = "";
                element.style.transition = "all 300ms";
              });
            }

            // 要素を更新する
            state.dndItems[itemIndex] = { key, value, element, position };
          },

          onMouseDown: (event: React.MouseEvent<HTMLElement>) => {
            // ドラッグする要素(DOM)
            const element = event.currentTarget;

            // マウスポインターの座標を保持しておく
            state.pointerPosition.x = event.clientX;
            state.pointerPosition.y = event.clientY;

            // ドラッグしている要素のスタイルを上書き
            element.style.transition = ""; // アニメーションを無効にする
            element.style.cursor = "grabbing"; // カーソルのデザインを変更

            // 要素の座標を取得
            const { left: x, top: y } = element.getBoundingClientRect();
            const position: Position = { x, y };

            // ドラッグする要素を保持しておく
            state.dragElement = { key, value, element, position };

            // mousemove, mouseupイベントをwindowに登録する
            window.addEventListener("mouseup", onMouseUp);
            window.addEventListener("mousemove", onMouseMove);
          }
        }
      };
    }
  );
};

あとがき

色々と対応出来てない部分もありますが、基本的な機能は実装出来たと思いますので、個人的には満足しています。

実は、今回作ったuseDnDSort()をライブラリとして出そうとも思ったんですが、そこまで使いどころが無いように感じたので公開しませんでした。というより、この記事書いて力尽きました orz。

アニメーションの解説については、正直分かりにくいかもしれません。。。
もし解説が分かりにくければ、ソースコードを修正しながら挙動を確認すると分かりやすいので、実際に動かして試してみる事をオススメします。CodeSandBox だと簡単に挙動を確認できるので、とても便利です ✨ どんどん活用していきましょう 💪

また、今回使わせて頂いたPexelsの画像の投稿者の皆様方に、この場をお借りして感謝したいと思います。素敵な画像をありがとうございました 🙇‍♂️

ここまで読んでくれてありがとうございます 🙏

記事に間違いなどがあれば、コメントなどで教えて頂けると嬉しいです。

これが誰かの参考になれば幸いです。
それではまた 👋

GitHubで編集を提案

Discussion

gotingotin

めちゃくちゃ丁寧な解説ありがとうございます🙏🏻

ところで「ローカル変数を定義する」のsectionの最初のソースコード内で
useDnDSortの定義がありますが、

// 状態をrefで管理する
  const state = useRef<DnDRef<T>>({
    dndItems] [],
    keys: new Map(),
    dragElement: null,
    canCheckHovered: true,
    pointerPosition: { x: 0, y: 0 }
  }).current;

↑こちら、正しくはこうですかね?↓

// 状態をrefで管理する
  const state = useRef<DnDRef<T>>({
    dndItems: [],
    keys: new Map(),
    dragElement: null,
    canCheckHovered: true,
    pointerPosition: { x: 0, y: 0 }
  }).current;

パラメタ定義のdndItemsの直後の ]: