🦀

react-dnd v16.0.1¦増やせるフォームをドラッグ&ドロップできるようにする

2023/01/12に公開

やりたいこと

  1. ボタンを押すと増えるフォームを作る(antdesignを使用しています)
  2. 1をドラッグアンドドロップして並び替えできるようにする
  3. create画面でもupdate画面でも使えるようにする

React DnDを使用したのですが、TypeScriptやReactに慣れていないこともあり結構手こずったのでわかる範囲でまとめました。
調べて実装しましたが2023年1月時点のことで、誤っている箇所がある可能性もありますので誤りを見つけた際はご指摘ください( ´~` )

React DnDにした理由について

  • 2023年1月時点で他のドラッグ&ドロップのライブラリに比べると更新している認識
  • HTML Drag and Drop APIを使用しているとのことなのでもし更新しなくなったとしても改修点が少なくなるのかなと考えた

などなど、、、

環境

"antd": "^4.24.4",
"next": "^12.3.1",
"react": "^18.2.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",

実装の考え方

  1. ボタンを押すと増えるフォームを作る
  2. フォームを入れ替える機能を作る
  3. ドラッグアンドドロップ機能の実装する
  4. ドロップした際に2の入れ替える機能を使う

これを順番に実装していきます。
今回はantdesignのForm.Listを使用しているためドラッグ&ドロップする位置をcssで指定するのではなく、要素の順番を並びを入れ替えます。(配列の中のオブジェクトの順番を並び替えている)
antdesignのmoveに圧倒的感謝🥲

ボタンを押すと増えるフォームの作成

※React DnDについてのみ知りたい方は飛ばしてください。ここは「実装の考え方」の2までの内容で、やることは下記の2点です。

  1. antdesignのForm.Listを使って動的に増やせるフォームを作る
  2. Form.Listにあるmove: (from: number, to: number) => void;を使えるようにする

antdesignでフォームの作成

ドキュメントのここにある内容ほぼそのままです🔍
codesandboxで動作確認する場合はこちら🌟

入力エリアが1つのフォームの作成

ボタンを押すとtextareaがひとつずつ増えるフォームを作成すると下記のような感じになります。

<Form.List name="process_contents" initialValue={initialProcesses}>
  {(fields, { add, remove, move }) => (
    <>
      {fields.map((field, index) => (
        <div key={index}>
          <Form.Item name={field.name}>
            <Input.TextArea />
          </Form.Item>
          <Space>
            <MinusCircleOutlined onClick={() => remove(field.name)} />
          </Space>
        </div>
      ))}
      <Form.Item noStyle>
        <Button
          type="dashed"
          onClick={() => add()}
          icon={<PlusOutlined />}
        >
          追加
        </Button>
      </Form.Item>
    </>
  )}
</Form.List>

入力エリアが複数のフォームの作成

ボタンを押すと横に3つ入力フォームが1セットずつ増えるフォームを作成すると下記のような感じになります。

<Form.List name="materials" initialValue={materials}>
  {(fields, { add, remove, move }) => (
    <>
      {fields.map((field, index) => (
        <div key={`materials-${field.key}`}>
          <Space>
            <Form.Item
              initialValue=""
              label="名称"
              name={[field.name, 'name']}
              key={`materialName-${field.key}`}
            >
              <Input />
            </Form.Item>

            <Form.Item
              initialValue=""
              label=""
              name={[field.name, 'amount']}
              key={`materialAmount-${field.key}`}
            >
              <Input />
            </Form.Item>

            <Form.Item
              initialValue=""
              label="単位"
              name={[field.name, 'unit']}
              key={`materialUnit-${field.key}`}
            >
              <Input />
            </Form.Item>

            <MinusCircleOutlined onClick={() => remove(field.name)} />
          </Space>
        </div>
      ))}
      <Form.Item noStyle>
        <Button
          type="dashed"
          onClick={() => add()}
          icon={<PlusOutlined />}
        >
          追加
        </Button>
      </Form.Item>
    </>
  )}
</Form.List>
動作は問題ないけど "A component is changing an uncontrolled input to be controlled." が表示される時💡

initialValueに入力の初期値の設定がされていないため最初に値を入力するとundefinedから入力値に変わり、制御されない入力から制御される入力に切り替わるため起こるようなのでinitialValueに値をセットすればok

<Form.List> // ここにinitialValue=""を追加するとupdate時の初期値が入る
  {(fields, { add, remove }) => (
    <>
      {fields.map((field, index) => (
        <Space>
          <Form.Item initialValue=""> // initialValue=""を追加
            <Input />
          </Form.Item>

          <Form.Item initialValue=""> // initialValue=""を追加
            <Input />
          </Form.Item>

          <MinusCircleOutlined onClick={() => remove(field.name)} />
        </Space>
      ))}
      <Form.Item noStyle>
        <Button>追加</Button>
      </Form.Item>
    </>
  )}
</Form.List>

DnD操作をするコンポーネントへ分割

<Form.List name="process_contents" initialValue={processes}>
  {(fields, { add, remove, move }) => (
    <>
      {fields.map((field, index, fields) => (
        <DraggableProcess
          key={`draggableProcess-${index}`}
          indexKey={index}
          field={field}
          fields={fields}
          remove={remove}
          move={move}
        />
      ))}
      <Form.Item noStyle>
        <Button>追加</Button>
      </Form.Item>
    </>
  )}
</Form.List>

export const DraggableProcess: React.FC<{
  indexKey: number;
  field: FormListFieldData;
  fields: FormListFieldData[];
  remove: (index: number | number[]) => void;
  move: (from: number, to: number) => void;
}> = ({ indexKey, field, fields, remove, move }) => {
  const name = field.name;
  const key = field.key;

  return (
    <div key={`processes-${key}`}>
      <Form.Item name={name} noStyle>
        <Input.TextArea />
      </Form.Item>
      <Space>
        <MinusCircleOutlined onClick={() => remove(name)} />
      </Space>
    </div>
  );
};

ドラッグ&ドロップ機能の作成

  • ドロップした際ドロップ可能エリアにいる場合は可能なエリアだよ〜と表示する
  • 3個以上ドロップ可能なフォームがある場合に一番最後の要素を先頭に持ってくると先頭から一個ずつずれるようにする(入れ替えではない)

他の方法も試したのですが動作が重かったりうまくいかない箇所があったので今回はuseDropdrop()useDragend()で並び替えるようにしました。

DndProviderの設定

使うところだけでもいいかもです。

<DndProvider backend={HTML5Backend}>
    <Component />
</DndProvider>

useDrop:ドロップ機能作成 📄

型を抜いてシンプルにすると下記になりました。

const [{ canDrop, isOver }, drop] = useDrop(() => ({
  accept: DRAGGABLE_TYPES.PROCESS,
  drop: (item, monitor) => {
    if (monitor.canDrop()) return { fieldData: item, indexKey: indexKey };
  },
  collect: (monitor) => {
    return {
      isOver: monitor.isOver(),
      canDrop: monitor.canDrop(),
    };
  },
}), [fields]);

refに戻り値のdropを渡してドロップ対象にする。
ドラッグから書きたいところなのですがドロップの内容がドラッグに繋がってくるので先にドラッグについて…

accept 📄

必須項目でuseDragtypeと合わせる必要がある。
useDragtypeは文字列型かシンボル型、useDropacceptは文字列型かシンボル型か配列型にする必要がありとのこと。
useDropacceptは配列型を受け入れるのは多分ここが理由そうでした。

The drop targets are very similar to the drag sources. The only difference is that a single drop target may register for several item types at once, and instead of producing an item, it may handle its hover or drop.

とりあえず単純に文字列型で指定するようにしました。
入力エリアが1つのフォームも入力エリアが複数のフォームもドラッグ&ドロップしたかったので定数にしました。

export const DRAGGABLE_TYPES = {
    PROCESS: 'draggable-process',
    MATERIAL: 'draggable-material',
};

drop 📄

オブジェクトを返した場合、それがドロップ結果となるのでuseDragend()からmonitor.getDropResult()で取れるようになるとのこと。
今回だとmonitor.getDropResult()でこのようなオブジェクトが返ってきます。

{
  dropEffect: "move",
  fieldData: {name: 0, key: 0},
  indexKey: 1,
}
  • useDropcanDrop()falseを返すとdrop()は呼び出されず、useDragend()monitor.getDropResult()の結果はnullになりました。
  • monitor.canDrop()でドロップ可能か判断していますが、私の実装だと<div ref={drop}></div>の範囲内かどうかを判断していると思われます。範囲から出るとnullが返ってきます。

collect 📄

React DnDに、ホバーmonitor.isOver()やドロップイベントmonitor.canDrop()最新の値を、すべてのCellinstancesにpropsとして渡すように指示し処理されることを知らせているとのこと。useDropの戻り値から取り出してホバー中なのか、ドロップ可能なのかで処理を分けることができます。

const [{ canDrop, isOver }, drop] = useDrop();

その他のSpecification Object Membersについて 📄

hover()canDrop()はhoverの判定やdrop可能かの判定を独自にしたい場合に設定するようでした。
canDrop()falseしか返さないようにしたらmonitor.canDrop()falseしか返ってこなくなります。普通はすぐわかるかもですが混乱しました、、、

useDrag:ドラッグ機能作成 🏃

型を抜いてシンプルにすると下記になりました。

const [{ isDragging, canDrag }, drag] = useDrag(() => ({
  type: DRAGGABLE_TYPES.PROCESS,
  item: { name, key },
  collect: (monitor) => ({
    isDragging: monitor.isDragging(),
    canDrag: monitor.canDrag(),
  }),
  end: (_, monitor) => {
    const dropResult = monitor.getDropResult() as {
      fieldData: DraggableFieldData;
      indexKey: number;
    };
    if (dropResult) {
      move(dropResult.fieldData.name, dropResult.indexKey);
    }
  },
}),[name, key]);

refに戻り値のdragを渡してドラッグ対象にする。

type 🏃

必須項目でuseDropacceptと合わせる必要がある。
先に書いた通り文字列型かシンボル型にする。

item 🏃

必須項目でオブジェクトか関数を指定するとのことなのですがこれが最初に一番混乱しました。
公式のチェスのコード見るとitemないんですよね、、、
神隠し?上級者にしか見えないどこかにあるのか私の勘違いなのかよくわかりません( ´~` )

useDropがわかるドラッグしているものの情報で複雑な参照ではなく最小限で渡してあげると良いとのことでuseDropdrop: (item, monitor)itemがこのitemとイコールになりました。

脱線するのですが公式のナイトをドラッグすると突然のリアルな馬でした🐴 古のニコ動感
uma...

end 🏃

useDropのdrop部分にある事とほぼ一緒なのですがuseDropdrop()の内容がmonitor.getDropResult()で取れます。

collect 🏃

useDropのcollectのdrag verのような感じ

完成

codesandboxで動作確認する場合はこちら🌟

import { DRAGGABLE_TYPES } from '@/types/Const';
import { MinusCircleOutlined } from '@ant-design/icons';
import { Space, Form, Input, Button, FormListFieldData } from 'antd';
import { useDrag, DragSourceMonitor, useDrop, DropTargetMonitor } from 'react-dnd';

type DraggableFieldData = {
  name: number;
  key: number;
};

export const DraggableProcess: React.FC<{
  indexKey: number;
  field: FormListFieldData;
  fields: FormListFieldData[];
  remove: (index: number | number[]) => void;
  move: (from: number, to: number) => void;
}> = ({ indexKey, field, fields, remove, move}) => {
  const name = field.name;
  const key = field.key;

  const [{ canDrop, isOver }, drop] = useDrop<DraggableFieldData, unknown, { isOver: boolean; canDrop: boolean }>(() => ({
    accept: DRAGGABLE_TYPES.PROCESS,
    drop: (item: DraggableFieldData, monitor: DropTargetMonitor<DraggableFieldData>) => {
      if (monitor.canDrop()) return { fieldData: item, indexKey: indexKey };
    },
    collect: (monitor: DropTargetMonitor<DraggableFieldData>) => {
      return {
        isOver: monitor.isOver(),
        canDrop: monitor.canDrop(),
      };
    },
  }),[fields]);

  const [{ isDragging, canDrag }, drag] = useDrag<DraggableFieldData, unknown, { isDragging: boolean; canDrag: boolean }>(() => ({
    type: DRAGGABLE_TYPES.PROCESS,
    item: { name, key },
    collect: (monitor: DragSourceMonitor<DraggableFieldData>) => ({
      isDragging: monitor.isDragging(),
      canDrag: monitor.canDrag(),
    }),
    end: (_, monitor: DragSourceMonitor<DraggableFieldData>) => {
      const dropResult = monitor.getDropResult() as { fieldData: DraggableFieldData; indexKey: number; };
      if (dropResult) {
        move(dropResult.fieldData.name, dropResult.indexKey);
      }
    },
  }), [name, key]);

  return (
    <div key={`processes-${key}`} ref={drop}>
      {isOver && canDrop && (
        <Button type="dashed" style={{ width: '90%', height: '150px', margin: 20 }} block>
          Release to Drop
        </Button>
      )}
      {!isOver && (
        <div
            ref={drag}
            style={{
                width: '100%',
                display: 'flex',
                position: 'relative',
                alignItems: 'center',
                justifyContent: 'center',
                cursor: canDrag ? 'move' : 'default',
                opacity: isDragging ? 0.5 : 1,
            }}
        >
          <Form.Item name={name} noStyle>
            <Input.TextArea />
          </Form.Item>
          <Space>
            <MinusCircleOutlined onClick={() => remove(name)} />
          </Space>
        </div>
      )}
    </div>
  );
};

🍩 🍩 🍩

終わりに

あまり使うことはないかもしれないですね( ´~` )
実装してほしいといわれたときはう〜〜〜む🤔と思ったのですが形になると感慨深いですね( '֊' )

今までフロント寄りの実装をする機会が少なくTypeScriptやReactなどまだまだ初心者なので今まで漠然とzennの機能ってすごいな〜くらいの認識だったのですが、久しぶりにzennに書いてる時にもしかしたらこの機能はあれを使っているのかな、すごいな〜って思うようになりました。少しだけ成長ですね🦀

Discussion