見出し画像

React DnDの基本的な使い方紹介

こんにちは、食べログフロントエンドチームの佐々木です。
5月初頭にリリースした食べログノートでは、D&Dの実装にReact DnDというライブラリを採用したので今回紹介しようと思います。
食べログノートについては以下記事を御覧ください。

React DnDとは

React向けのD&D用ライブラリです。
UIコンポーネントは含まれておらず、既存のコンポーネントをラップして利用します。
Touchバックエンドを利用することで、タッチ操作でのD&Dも実装可能です。
詳しくは以下の公式ページを御覧ください。

食べログノートではタイムスケジュール画面のD&Dやメニュー/コース一覧画面のメニューのソートなどに利用しています。
下記を見てもわかるように広範囲の実装が可能です。

食べログノート タイムスケジュール
食べログノート メニュー/コース一覧

Hooks

React DnDでは3つのカスタムフックを利用します。

useDrag

  const [collected, drag, dragPreview] = useDrag(() => ({
    type,
    item: { id }
  }))

戻り値の配列の2要素目をコンポーネントのrefに渡すことでドラッグ可能なコンポーネントになります。
また、itemを設定することでuseDragLayer、useDropにドラッグ中のコンポーネントに関するデータを共有することができます。

useDragLayer

  const collectedProps = useDragLayer(
    monitor => /* Collected Props */
  )

ドラッグしているか否か、ドラッグ中のコンポーネントのデータ(useDragのitemに設定したもの)、ドラッグ中のコンポーネントの座標を取得できます。
これらの値を用いてドラッグ中にデフォルトで表示される画像の代わりにコンポーネントを表示することができます。
ドラッグ中のコンポーネントとは全く異なるコンポーネントも表示可能なので様々な使い方ができます。

useDrop

  const [collectedProps, drop] = useDrop(() => ({
    accept
  }))

戻り値の配列の2要素目をコンポーネントのrefに渡すことでドロップ対象のコンポーネントになります。ドロップ時に実行する関数を指定可能です。
ここでドロップしたコンポーネントの座標の更新処理等を実装します。

実際の使い方

サンプルコードをもとに実際のReact DnDの使い方を説明します。
作成するのは以下のようなコンポーネントです。
CSSにはstyled-componentsを利用しています。

ドラッグ可能なコンポーネントの作成

import { FC, useEffect } from "react";
import { useDrag } from "react-dnd";
import { getEmptyImage } from "react-dnd-html5-backend";
import styled, { css } from "styled-components";

export const CARD_WIDTH = 100;
export const CARD_HEIGHT = 50;

const StyledDiv = styled.div<{
  top: number;
  left: number;
  isDragging: boolean;
}>`
  position: absolute;
  box-sizing: border-box;
  display: grid;
  place-items: center;
  width: ${CARD_WIDTH}px;
  height: ${CARD_HEIGHT}px;
  color: white;
  background-color: blue;
  ${({ top, left }) => css`
    top: ${top}px;
    left: ${left}px;
  `};
  ${({ isDragging }) => (isDragging ? "opacity: 0;" : "")}
`;
const StyledP = styled.p`
  font-size: 20px;
  font-weight: bold;
`;

export const CARD_TYPE = "Card";

export type CardItem = {
  coordinates: {
    top: number;
    left: number;
  };
  name: string;
  id: string;
};

export const DraggableCard: FC<{
  top: number;
  left: number;
  name: string;
  id: string;
}> = ({ top, left, name, id }) => {
  const [{ isDragging }, drag, preview] = useDrag<
    CardItem,
    Record<string, never>,
    { isDragging: boolean }
  >(
    () => ({
      type: CARD_TYPE,
      item: {
        coordinates: {
          top,
          left,
        },
        name,
        id,
      },
      collect: (monitor) => ({
        isDragging: monitor.isDragging(),
      }),
    }),
    [top, left, name, id]
  );
  useEffect(() => {
    preview(getEmptyImage());
  }, []);
  return (
    <StyledDiv top={top} left={left} isDragging={isDragging} ref={drag}>
      <StyledP>{name}</StyledP>
    </StyledDiv>
  );
};

useDragの返却値の第2要素(drag)をコンポーネントのrefに渡すことでドラッグ可能なコンポーネントにしています。
useDragへ渡す無名関数はコンポーネントのtype、useDropやuseDragLayerへコンポーネントの情報を共有するためのitem、monitorからデータを取得するためのcollectを返却しています。collectのデータはuseDragの返却値の第1要素から取得可能です。
isDraggingがtrueのときにopacity: 0を設定することでドラッグ中に元の位置にコンポーネントが表示されないようにしています。
useDragの返却値の第3要素(preview)をgetEmptyImage()を引数にして呼び出すことで、デフォルトのプレビューイメージが表示されないようにしています。

プレビュー用のコンポーネントの作成

import { FC } from "react";
import { useDragLayer } from "react-dnd";
import styled from "styled-components";
import { CardItem, CARD_HEIGHT, CARD_WIDTH } from "./DraggableCard";

type StyledDivProps = {
  top: number;
  left: number;
  x: number;
  y: number;
};
const StyledDiv = styled.div.attrs<StyledDivProps>(({ top, left, x, y }) => ({
  style: {
    left: `${left}px`,
    top: `${top}px`,
    transform: `translate(${x}px, ${y}px)`,
  },
}))<StyledDivProps>`
  position: absolute;
  will-change: transform;
  box-sizing: border-box;
  display: grid;
  place-items: center;
  width: ${CARD_WIDTH}px;
  height: ${CARD_HEIGHT}px;
  color: white;
  background-color: #2bff00;
`;

const StyledP = styled.p`
  font-size: 20px;
  font-weight: bold;
`;

export const DragLayer: FC = () => {
  const { item, offsetDifference, isDragging } = useDragLayer((monitor) => ({
    item: monitor.getItem() as CardItem,
    offsetDifference: monitor.getDifferenceFromInitialOffset(),
    isDragging: monitor.isDragging(),
  }));

  if (!isDragging || !differenceOffset || !item) {
    return null;
  }
  return (
    <StyledDiv
      top={item.coordinates.top}
      left={item.coordinates.left}
      x={offsetDifference.x}
      y={offsetDifference.y}
    >
      <StyledP>{item.name}</StyledP>
    </StyledDiv>
  );
};

useDragLayerを利用し、ドラッグ中にデフォルトのプレビューイメージの代わりに表示するコンポーネントを作成します。
useDragLayerにmonitorを受け取りデータを返す無名関数を渡します。無名関数の返却値はuseDragLayerの返却値として受け取ることができます。この実装では、ドラッグ中のコンポーネントで設定したデータ(item)、ドラック中のコンポーネントの初期位置からの差分(offsetDifference)、ドラッグ中か否か(isDragging)を取得しています。
isDraggingがtrueの場合のみ、これらのデータを利用しコンポーネントを表示します。

ドロップ対象のコンポーネントの作成

import { FC, useState } from "react";
import { useDrop } from "react-dnd";
import styled from "styled-components";
import {
  CardItem,
  CARD_HEIGHT,
  CARD_TYPE,
  CARD_WIDTH,
  DraggableCard,
} from "./DraggableCard";
import { DragLayer } from "./DragLayer";

const AREA_SIDE_LENGTH = 500;

const StyledDiv = styled.div`
  position: relative;
  display: grid;
  place-items: center;
  width: ${AREA_SIDE_LENGTH}px;
  height: ${AREA_SIDE_LENGTH}px;
  background-color: #fcf;
  border: 1px;
`;

const StyledP = styled.p`
  font-size: 50px;
  font-weight: bold;
`;

export const DroppableArea: FC = () => {
  const [cardData, setCardData] = useState([
    { top: 100, left: 100, name: "CARD1", id: "1" },
    { top: 200, left: 200, name: "CARD2", id: "2" },
  ]);
  const [, drop] = useDrop<CardItem, void, Record<string, never>>(
    () => ({
      accept: [CARD_TYPE],
      drop: (item, monitor) => {
        const coord = monitor.getSourceClientOffset();
        if (coord === null) return;
        if (
          coord.x < 0 ||
          coord.x > AREA_SIDE_LENGTH - CARD_WIDTH ||
          coord.y < 0 ||
          coord.y > AREA_SIDE_LENGTH - CARD_HEIGHT
        ) {
          return;
        }
        if (coord) {
          setCardData((prev) => [
            ...prev.filter((data) => data.id !== item.id),
            {
              top: coord.y,
              left: coord.x,
              name: item.name,
              id: item.id,
            },
          ]);
        }
      },
    }),
    [setCardData]
  );
  return (
    <StyledDiv ref={drop}>
      <StyledP>Droppable Area</StyledP>
      <DragLayer />
      {cardData.map(({ top, left, name, id }) => (
        <DraggableCard key={id} top={top} left={left} name={name} id={id} />
      ))}
    </StyledDiv>
  );
};

useDropを利用しドロップ対象のコンポーネントを作成します。
useDropの返却値の第2要素(drop)をコンポーネントのrefに渡すことでドロップ対象のコンポーネントにしています。
useDropへ渡す無名関数はドロップを受け付けるコンポーネントのtypeの配列(accept)、ドロップした際に実行する関数(drop)を返却しています。dropではドラッグ中のコンポーネントで設定したデータ(item)とmonitorを受け取り、枠外ではないかの判定とstateの更新を行っています。
refにdropを受け取ったdiv要素のchildrenには上で作成したDragLayerとDraggableCardを設定しています。

DndProviderでのラップとbackendの設定

import { FC } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { DroppableArea } from "./DroppableArea";

export const DndExample: FC = () => (
  <DndProvider backend={HTML5Backend}>
    <DroppableArea />
  </DndProvider>
);

最後に作成したコンポーネントをDndProviderのchildrenにします。今回backendにはHTML5Backendを利用しています。

まとめ

今回は食べログノートでD&Dの実装に利用したReact DnDの概要と実際の使い方について紹介しました。
ライブラリがUIコンポーネントを提供していないこともあり、理解するまで多少時間がかかるのですが、その分汎用性が高く将来的なサービスの追加開発でも心配のいらないライブラリだと思います。
今回の記事がReact Dnd利用の際に少しでもお役に立てたら幸いです。

最後に

現在、食べログではフロントエンドに関わるポジションとして以下の2つを募集しています。
気になったかたは是非チェックしてみてください!

・フロントエンド統括チームに所属するフロントエンドエンジニア
・フロントエンドをメインにサービス開発を担当していくWEBエンジニア

・難しい課題にチーム一丸となって取り組みたい
・React/TypeScriptでバリバリ開発したい
・レガシーなシステムのリファクタリングがしたい
・アーキテクチャについて探求したい
・食べログというプロダクトに貢献したい
・大規模なシステムの開発に携わりたい
・柔軟に働ける環境で自分のスキルを活かしたい

どれかに当てはまった方は以下のリンクも是非御覧ください!


いいなと思ったら応援しよう!