🐿️

【React】ドラッグ&ドロップで並べ替えできるリストを実装する話

2022/11/04に公開

2022/11/14 追記:
完成例において無駄に再計算が行われていたので、適切なメモ化を行うようにしました。

前置き

「リストに並び替えを実装したい」
「でもライブラリを使うほどでもないな…」
ってなったとき用に使いまわしのききそうな軽めの実装を考えてみたので共有します。

実装の方針

  1. なるべくWeb APIの機能を使い自力実装を避ける
    • onMouseDownposition: absoluteで座標の管理とかはしない
  2. iOSやAndroidのブラウザでも最低限動くようにする
  3. 汎用性を持たせる
    • 並び替え機能をデザインと切り離して考える

ドラッグ&ドロップの処理

移動の開始と終了の検知

Event Memo
onDragStart draggableな要素をドラッグすると発生
onDragEnd ドラッグが終了したときにドラッグしていた要素に対して発生

上記2つのイベントで開始と終了は検知できます。

MDN

移動先の決定方法

Event Memo
onDragEnter ドラッグ状態でカーソルが要素に触れると1回だけ発生
onDragOver ドラッグ状態でカーソルが要素に触れていると数ミリ秒単位で発生
onDragLeave ドラッグ状態でカーソルが要素から出ると発生
onDrop 要素に対してドロップされたときに発生

すべてカーソル位置も取得できます。(event.clientX,event.clientY)

MDN

ドラッグ状態で触れた要素のindexがそのまま移動先になると考えるのが1番簡単そうです。

やりたいこと
やりたいこと

これが検知できるのはonDragEnter,onDragOver,onDropということになります。
ただ、これだと末尾に移動させることができません。
なのでカーソルが触れている位置で移動先を変えることにします。

やりたいこと2
「上側 → index」「下側 → index + 1」
加えてゴースト(またはキャレット)の表示位置はドラッグ中に移動先が変わるたび更新しないといけないので、移動先の決定にはonDragOverを使用することになります。

ミニマムな実装

以上を踏まえてミニマムに実装したのがこちらです。(ゴーストなどは未実装)

補足

iOSのSafariではdraggable属性だけではなく、CSSで -webkit-user-darg: elementを付けていないとonDragStartが発生しません。

また、onDragStart内で適切にDataTransferに対してデータを設定しないとドラッグか開始されませんでした。

参考

ゴーストの処理

このままでは移動先が分かりづらいので、ゴーストを表示します。

ゴーストの表示位置は移動先のindexがそのまま使えます。

ただゴーストを表示した場合「位置がずれる」「ゴースト自体にもカーソルが当たる」といった問題が発生します。

そのため、ゴースト自体にもその他の要素と同様にonDragOver時の動作を設定する必要があります。

汎用性を持たせる

リストアイテムの表示を自由にする

コンポーネントのpropsで表示方法を受け取るようにします。
具体的にはJSX.Elementを返すメソッドを渡してもらうことで、表示が必要になったときに実行してJSX.Elementを生成します。

ゴーストでも同じことを行い表示の縛りをなくします。

2022/11/14 追記
props.childrenでReactNodeではなくメソッドを受け取っています。
他のpropsでメソッドを受け取るよりも記述量が減り、コンポーネントの使い方が明確になるのでオススメです。
(SolidJSの<For>と同じ感じ)

実装の例
Propsの定義
export type Item<T = any> = {
  id: string;
  data: T;
};

type HandleProps = {
  draggable: true;
  onDragStart?: (event: React.DragEvent) => void;
  onDragEnd?: (event: React.DragEvent) => void;
};

type ItemProps = {
  key: string;
  ref: (elm: HTMLElement | null) => void;
  onDragEnter?: (event: React.DragEvent) => void;
  onDragOver?: (event: React.DragEvent) => void;
  onDragLeave?: (event: React.DragEvent) => void;
  onDrop?: (event: React.DragEvent) => void;
};

type ItemViewParams<T = any> = {
  item: Item<T>;
  index: () => number;
  handleProps: HandleProps;
  itemProps: ItemProps;
};

type GhostViewParams<T = any> = {
  item: Item<T>;
  ghostProps: Omit<ItemProps, "ref">;
};

type Props<T = any> = {
  initItems: Item<T>[];
  direction?: "vertical" | "horizontal";
  onChange?: (newItems: Item<T>[]) => void;
  // ReactNodeではなくメソッドを受け取る
  // 必須にすることで何が必要か一目でわかる
  children: (params: ItemViewParams<T>) => JSX.Element;
  ghost?: (params: GhostViewParams<T>) => JSX.Element;
};
生成するところ
// props.children -> itemView
const views = useMemo(() => {
  return items.map((item) => {
    return itemView({
      item,
      // ↓アイテムコンポーネント内から並び替えなどをする場合に必要になりそう(上に移動ボタンとか)
      index: () => items.findIndex((target) => target.id === item.id),
      handleProps: getHandleProps(item),
      itemProps: getItemProps(item),
    });
  });
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [items, itemView]);
呼び出し側
<Draggable
  initItems={list}
  onChange={(items) => setList(items)}
  ghost={({ ghostProps }) => (
    <li className="caret-y" {...ghostProps}></li>
  )}
>
  {/* ReactNodeではなくメソッドをchildrenに渡す */}
  {({ item, handleProps, itemProps }) => (
    <li className="item" {...handleProps} {...itemProps}>
      <TaskItem initData={item.data} />
    </li>
  )}
</Draggable>

横並びに対応する

onDragOverの計算で縦軸だった部分をpropsに応じて横軸にもできるようにします。

onDragOver
onDragOver(event) {
  event.preventDefault();
  const elm = $refs.current.get(item.id);
  if (!elm) return;
  const rect = elm.getBoundingClientRect();
  const posX = event.clientX - rect.left;
  const posY = event.clientY - rect.top;
  const ratioX = Math.min(1, Math.max(0, posX / rect.width));
  const ratioY = Math.min(1, Math.max(0, posY / rect.height));
  // 縦横を切り替える
  setTargetIndex(
    index + Math.round(direction === "vertical" ? ratioY : ratioX)
  );
}

CSS用に属性値の追加

  • ドラッグ中の要素にdata-active属性

2022/11/14 追記
既に生成済みのJSX.ElementをReact.cloneElement複製して属性値のみ書き換えています。
こうすることで再計算を抑えることができます。

// activeIndexだったらクローンを作成して属性値を付与
const viewWithAttr = cloneElement(view, {
  "data-active": true
});

最終的な実装

以上を踏まえてできたものがこちらです。

並び替え機能はDraggable.tsxに閉じ込めて、デザインやその他の動作は外に出すことができています。

問題点と結論

  • 改行があるリストだとゴーストの表示位置が残念
  • ゴースト表示時にカクっとなるのでストレス
  • CSSが煩雑になりがち
  • アイテムの隙間(gapなど)ではカーソルがドロップ不可の表示になる
  • スマホだとドラッグ開始するのに長押しが必要(わかりづらい)
  • iOSのSafariだとドラッグ中に別の指で要素をタップすると複数ドラッグになるが対応できていない
  • そもそも全然軽めの実装ではない気がする

結局こだわりたいのであればライブラリを使うのがいいという結論に至りました。

何かしらの参考になれば幸いです。

ありがとうございました。

Discussion