最近、業務でReactDnDを利用したドラッグ&ドロップを実装したので、その学びを踏まえて公式ドキュメントのSortable Simpleをタッチデバイス対応にしてみました。
作成したコードはこちらになります。
本記事の流れ
- ReactDnDとは
- バックエンドモジュール
- ドラッグ&ドロップ実装
- DragLayerによるプレビュー実装
#React DnDとは
Reactでドラッグ&ドロップが実現できるライブラリです。登場は2014年ですが、2022年1月現在も継続的にメンテナンスされており、hooksも提供されています。
#バックエンドモジュール
React DnDはドラッグ&ドロップの処理を、バックエンドモジュールを指定したプロバイダー内部に書く必要があります。公式ではHTML5Backendを指定することが推奨されていますが、こちらのモジュールはタブレットなどのタッチデバイスには対応していません。
function App() {
return (
<div className="App">
<DndProvider backend={HTML5Backend} >
{/* プロバイダーの中にドラッグ&ドロップの処理を配置する */}
</DndProvider>
</div>
);
}
そこでタッチイベントに対応したモジュールであるTouchBackendを代わりに用います。こちらを利用することで、タッチデバイスでもドラッグ&ドロップが可能になります。
しかし、TouchBackendだと今度はドラッグ時のプレビューが表示されません。
HTML5Backendの時はReactDnD側ではなく、HTML5Backend側のAPIを利用することでドラッグ中のプレビュー表示を実現していました。TouchBackendだとそれがないため、自前で実装する必要があります。
#ドラッグ&ドロップ実装
ドラッグ&ドロップ自体の実装は公式のコードを引っ張ってきています。
以下は今回のドラッグ対象かつドロップ対象であるCardコンポーネントです。
export const Card: FC<CardProps> = ({ id, text, index, moveCard }) => {
const ref = useRef<HTMLDivElement>(null);
// collectでmonitor経由で取得した値を戻り値として外に渡せる
const [{ handlerId }, drop] = useDrop({
accept: ItemTypes.CARD,
collect(monitor) {
return {
handlerId: monitor.getHandlerId(),
};
},
// ドラッグ中にドロップ対象にhoverしている時のcallback関数を定義
hover(item: DragItem, monitor: DropTargetMonitor) {
if (!ref.current) {
return;
}
// ドラッグ中のアイテムのindexを取得
const dragIndex = item.index;
// ドラッグ中にhoverしているドロップ対象のアイテムのindexを取得
const hoverIndex = index;
if (dragIndex === hoverIndex) {
return;
}
// refからドラッグ中のアイテムの初期位置を取得
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
// monitorからカーソルの位置を取得
const clientOffset = monitor.getClientOffset();
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
// カーソルがドロップ対象のアイテムの高さの半分を超えるまでは、並び替えを実行しない
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
// 高さの半分を超えた時点で並び替えを実行
moveCard(dragIndex, hoverIndex);
item.index = hoverIndex;
},
});
// collectでmonitor経由で取得した値を戻り値として外に渡せる
const [{ isDragging }, drag] = useDrag({
type: ItemTypes.CARD,
item: () => {
return { id, index };
},
collect: (monitor: any) => ({
isDragging: monitor.isDragging(),
}),
});
// ドラッグ中はドラッグされているアイテムを非表示にしておく
const opacity = isDragging ? 0 : 1;
// refとuseDrag、useDropを紐づける
drag(drop(ref));
return (
<div ref={ref} style={{ ...style, opacity }} data-handler-id={handlerId}>
{text}
</div>
);
};
ドラッグ&ドロップの処理はuseDrop、useDragといったhooksを利用することで、それぞれドロップ時、ドラッグ時の挙動を設定できます。
今回はドラッグ中のカーソルがドロップ対象にhoverした時点で並び替えするように実装されているため、useDrop内ではhoverしている時のcallback関数を定義しています。
各hooksではcollectという関数を介すことで、内部の状態を返り値として外で受け取ることができます。ここでは現在ドラッグしているかどうかの状態(isDragging)をuseDragから返り値として受け取り、ドラッグ中は元々そのアイテムがあった箇所を非表示にするopacityを適用しています。
また、ドラッグ時のプレビューの実装をする際に、div内に表示するテキストが必要になるため、itemの返り値にtextを追加しておきます。
const [{ isDragging }, drag] = useDrag({
type: ItemTypes.CARD,
item: () => {
// itemの返り値にtextを追加
return { id, index, text };
},
collect: (monitor: any) => ({
isDragging: monitor.isDragging(),
}),
});
#DragLayerによるプレビュー実装
ここからドラッグ時のプレビューを実装します。ReactDnDにはDragLayerというプレビュー表示を作成できるAPIがあるので、このhooksを用いたコンポーネントを作成します。
DragLayerのコンポーネントはドラッグ&ドロップ実装と同様プロバイダー配下に配置する必要があります。
function App() {
return (
<div className="App">
{/* タッチでもマウスでもドラッグできるようにオプションを指定 */}
<DndProvider backend={TouchBackend} options={{ enableMouseEvents: true }}>
{/* ドラッグ&ドロップができるコンポーネントの描写を行う */}
<Example />
{/* プレビューの描写を行う */}
<SampleDragLayer />
</DndProvider>
</div>
);
}
次にプレビューのコンポーネントであるSampleDragLayerの実装です。
今回作成したSampleDragLayerのフォルダ構成とコードは以下になります。
- SampleDragLayer
- hooks.ts(ロジックをまとめたカスタムフック)
- index.tsx(カスタムフックとDOM構造を呼び出す)
- presenter.tsx(DOM構造を管理)
export const useSampleDragLayer = () => {
const { item, isDragging, initialOffset, differenceOffset } = useDragLayer(
(monitor) => ({
// ドラッグしているアイテムの初期位置を取得
initialOffset: monitor.getInitialSourceClientOffset(),
// ドラッグ開始位置から現在のカーソル位置までの差分を取得
differenceOffset: monitor.getDifferenceFromInitialOffset(),
// useDragのItemに渡していた要素をここから取得
item: monitor.getItem(),
isDragging: monitor.isDragging(),
})
);
if (!isDragging || !differenceOffset || !initialOffset) {
return { text: "", isDragging: isDragging, x: 0, y: 0 };
}
return {
text: item.text,
isDragging: isDragging,
// 以下でプレビューを表示したい座標を計算
// スクロールで表示の初期位置がずれてしまうのでwindow.scrollX、window.scrollYで補正
x: differenceOffset.x + initialOffset.x + window.scrollX,
y: differenceOffset.y + initialOffset.y + window.scrollY,
};
};
useDragLayer内のmonitorは先程定義したuseDragのmonitorと紐づいているため、プレビューの描写に必要なアイテムの情報とドラッグ状態、カーソルの位置情報などを返り値から受け取ります。
スクロールで表示位置でずれる部分はwindow.scrollX、window.scrollYで補正しました。
export const SampleDragLayer: FC = () => {
const { isDragging, ...props } = useSampleDragLayer();
// ドラッグ中じゃない時はプレビューのコンポーネントを返さない
if (!isDragging) {
return null;
}
return <SampleDragLayerPresenter {...props} />;
};
カスタムフックからpresenterに渡すpropsを受け取ります。
ドラッグ中以外はプレビューを表示したくないので、isDraggingがtrueの時はnullを返します。
const style = {
border: "1px dashed gray",
padding: "0.5rem 1rem",
marginBottom: ".5rem",
backgroundColor: "white",
cursor: "move",
};
type Props = {
text: string;
x: number;
y: number;
};
export const SampleDragLayerPresenter: FC<Props> = ({ text, x, y }) => {
return (
<div
style={{
width: 400,
zIndex: 10,
position: "absolute",
top: 0,
left: 0,
transform: `translate(${x}px, ${y}px)`,
// こいつが無いとドロップ処理が上手くいかない
pointerEvents: "none",
}}
>
<div style={{ zIndex: 10, ...style }}>{text}</div>
</div>
);
};
プレビューを表示すべき座標のpropsを受け取り、スタイルに適用します。
プレビューが表示された!
#終わりに
今回はReactDnD公式サンプルのコードをタッチデバイス対応にしてみました。
ドラッグ&ドロップのライブラリとしてはreact-beautiful-dndも有名なので、今後そちらも触って機能面や使いやすさを比較してみたいです。
ここまで読んでいただきありがとうございましたmm
#参考サイト