React
, ReactDOM
, nanoid
以外の依存ライブラリZEROでお送りします。
こういうのができる
repo: https://github.com/konbu310/react-dnd-impl
demo: https://konbu310.github.io/react-dnd-impl/index.html
src/Dnd.tsx
がメインです。
まずは必要なrefなどを定義していきます。
Dnd.tsx(42行目あたり~)
// リストアイテムの親コンポーネント。inserBeforeするために使う
const containerRef = useRef<HTMLDivElement | null>(null);
// 各リストアイテム。idをkeyにするこの方法を思いついたときは歓喜した。
const itemsRef = useRef<{ [id: string]: HTMLDivElement | null }>({});
// ドラッグ中のリストアイテム。
const draggingElmId = useRef<string | null>(null);
// Stateの複製。余計な再レンダリングを防ぐためにuseRefで保持しておく。
const listClone = useRef<State>(list);
// propsから新しいStateが渡されてきた時にrefを更新するため。
useEffect(() => {
listClone.current = list;
}, [list]);
// touchmoveでスクロールしてしまうのを防ぐ。最適解がわからない。
useEffect(() => {
if (!itemsRef.current || !listClone.current) return;
listClone.current.map(
item => {
itemsRef.current[item.id]?.addEventListener("touchmove", ev =>
ev.preventDefault()
);
},
{ passive: false }
);
}, [itemsRef]);
refの指定とかはこんなかんじ。
Dnd.tsx(157行目あたり~)
return (
<div
className="container"
// containerRef
ref={containerRef}
onDrop={preventDefaultEvent}
onDragOver={preventDefaultEvent}
onDragEnter={preventDefaultEvent}
>
{list.map(item => (
<div
key={item.id}
className="list-item"
// TouchEventで使うためのカスタムデータ属性。これも最適解がわからない。
data-id={item.id}
// itemsRef
ref={elm => (itemsRef.current[item.id] = elm)}
draggable
// DragEventとTouchEventのハンドラたち
onDragStart={onDrag.start(item.id)}
onDragEnter={onDrag.enter(item.id)}
onDragEnd={onDrag.end(item.id)}
onDragOver={preventDefaultEvent}
// onDropも指定しておかないとドラッグ終了時にドラッグ中の画像が初期位置に戻るようにアニメーションしちゃう
// ちょっと言葉で説明するの無理ある
onDrop={preventDefaultEvent}
onTouchStart={onTouch.start(item.id)}
onTouchMove={onTouch.move(item.id)}
onTouchEnd={onTouch.end(item.id)}
>
{item.value}
</div>
))}
</div>
);
DragEventのハンドラはこんなかんじ
TypeScript
且つStrict Mode
なのでif文がめちゃんこ多い
Dnd.tsx(63行目あたり~)
// まとめてみたけどまとめないほうがいいかも
const onDrag: DragHandlers = {
// `onDragStart` ドラッグを開始した要素で発火
start: id => ev => {
// ドラッグ中の要素のidを保持しておく
draggingElmId.current = id;
// これは多分やっておいたほうがいい
// 画像を差し替えたいときは`ev.dataTranser.setDragImage()`
ev.dataTransfer.effectAllowed = "move";
// 1frずらしてやると本来は指定できない`opacity: 0`とかも指定できる
requestAnimationFrame(() => {
itemsRef.current[id]?.classList.add("list-item-dragging");
});
},
// `onDragEnter` draggable要素をドラッグ中のマウスカーソルが進入してきた時に進入された要素で発火
enter: id => ev => {
// 親要素
const containerElm = containerRef.current;
if (!draggingElmId.current || !containerElm) return;
// ドラッグ中の要素
const draggingElm = itemsRef.current[draggingElmId.current];
// ドラッグ先の要素(マウスカーソルの下にある要素)
const underElm = itemsRef.current[id];
if (!underElm || !draggingElm || underElm === draggingElm) return;
// ドラッグ中の要素のインデックス
const draggingElmIndex = listClone.current.findIndex(
item => item.id === draggingElmId.current
);
// 移動先の要素のインデックス
const underElmIndex = listClone.current.findIndex(item => item.id === id);
// インデックスの大小に応じてinsertBeforeを使用して並び替え
if (draggingElmIndex === underElmIndex) {
return;
} else if (draggingElmIndex < underElmIndex) {
containerElm.insertBefore(underElm, draggingElm);
} else if (draggingElmIndex > underElmIndex) {
containerElm.insertBefore(draggingElm, underElm);
}
// 配列のデータも移動させておく。`arrayMove`は`src/util.ts`で定義してるインデックスで配列の要素を移動させるための関数。
listClone.current = arrayMove(
listClone.current,
draggingElmIndex,
underElmIndex
);
},
// `onDragEnd` ドラッグ終了時に発火
end: id => ev => {
// 必要に応じてclassNameを削除したりremoveAttributeしたり
itemsRef.current[id]?.classList.remove("list-item-dragging");
// 変更をstateに適用する
setList(listClone.current);
}
};
TouchEventのハンドラも似たようなかんじ
touchmove
だけ少しトリッキー
あとスクロールを無理やり封じてしまったのでviewport外の要素にいけない(つらい)
Dnd.tsx(109行目あたり~)
const onTouch: TouchHandlers = {
// `ontouchstart` タッチを開始した要素で発火
start: id => ev => {
draggingElmId.current = id;
requestAnimationFrame(() => {
itemsRef.current[id]?.classList.add("list-item-dragging");
});
},
// `ontouchmove` タッチした状態で指を動かした時に発火
// ここがDragEventとの違い。`ontouchenter`ほしい...
move: id => ev => {
const containerElm = containerRef.current;
const draggingElm = itemsRef.current[id];
if (!containerElm || !draggingElm) return;
// タッチしてるとこの座標から無理くりドラッグ先の要素を取得する
const { clientX, clientY } = ev.targetTouches[0];
const underElm = document.elementFromPoint(clientX, clientY);
if (!underElm || draggingElm === underElm) return;
// カスタムデータ属性からidを取得する
// これ何かいい方法ないですかねぇ...
const underElmId = underElm.getAttribute("data-id");
if (!underElmId) return;
const draggingElmIndex = listClone.current.findIndex(
item => item.id === draggingElmId.current
);
const underElmIndex = listClone.current.findIndex(
item => item.id === underElmId
);
if (draggingElmIndex === underElmIndex) {
return;
} else if (draggingElmIndex < underElmIndex) {
containerElm?.insertBefore(underElm, draggingElm);
} else if (draggingElmIndex > underElmIndex) {
containerElm?.insertBefore(draggingElm, underElm);
}
listClone.current = arrayMove(
listClone.current,
draggingElmIndex,
underElmIndex
);
},
end: id => ev => {
itemsRef.current[id]?.classList.remove("list-item-dragging");
setList(listClone.current);
}
};
もっとスマートな方法ありそう。
次は仮想スクロールと合体させたい。