【React】ドラッグ&ドロップで並べ替えできるリストを実装する話
2022/11/14 追記:
完成例において無駄に再計算が行われていたので、適切なメモ化を行うようにしました。
前置き
「リストに並び替えを実装したい」
「でもライブラリを使うほどでもないな…」
ってなったとき用に使いまわしのききそうな軽めの実装を考えてみたので共有します。
実装の方針
- なるべくWeb APIの機能を使い自力実装を避ける
-
onMouseDown
やposition: absolute
で座標の管理とかはしない
-
- iOSやAndroidのブラウザでも最低限動くようにする
- 汎用性を持たせる
- 並び替え機能をデザインと切り離して考える
ドラッグ&ドロップの処理
移動の開始と終了の検知
Event | Memo |
---|---|
onDragStart |
draggable な要素をドラッグすると発生 |
onDragEnd |
ドラッグが終了したときにドラッグしていた要素に対して発生 |
上記2つのイベントで開始と終了は検知できます。
MDN
移動先の決定方法
Event | Memo |
---|---|
onDragEnter |
ドラッグ状態でカーソルが要素に触れると1回だけ発生 |
onDragOver |
ドラッグ状態でカーソルが要素に触れていると数ミリ秒単位で発生 |
onDragLeave |
ドラッグ状態でカーソルが要素から出ると発生 |
onDrop |
要素に対してドロップされたときに発生 |
すべてカーソル位置も取得できます。(event.clientX
,event.clientY
)
MDN
ドラッグ状態で触れた要素のindexがそのまま移動先になると考えるのが1番簡単そうです。
やりたいこと
これが検知できるのはonDragEnter
,onDragOver
,onDrop
ということになります。
ただ、これだと末尾に移動させることができません。
なのでカーソルが触れている位置で移動先を変えることにします。
「上側 → 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>
と同じ感じ)
実装の例
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(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