react-dnd v16.0.1¦増やせるフォームをドラッグ&ドロップできるようにする
やりたいこと
- ボタンを押すと増えるフォームを作る(antdesignを使用しています)
- 1をドラッグアンドドロップして並び替えできるようにする
- 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",
実装の考え方
- ボタンを押すと増えるフォームを作る
- フォームを入れ替える機能を作る
- ドラッグアンドドロップ機能の実装する
- ドロップした際に2の入れ替える機能を使う
これを順番に実装していきます。
今回はantdesignのForm.List
を使用しているためドラッグ&ドロップする位置をcssで指定するのではなく、要素の順番を並びを入れ替えます。(配列の中のオブジェクトの順番を並び替えている)
antdesignのmoveに圧倒的感謝🥲
ボタンを押すと増えるフォームの作成
※React DnDについてのみ知りたい方は飛ばしてください。ここは「実装の考え方」の2までの内容で、やることは下記の2点です。
- antdesignの
Form.List
を使って動的に増やせるフォームを作る -
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個以上ドロップ可能なフォームがある場合に一番最後の要素を先頭に持ってくると先頭から一個ずつずれるようにする(入れ替えではない)
他の方法も試したのですが動作が重かったりうまくいかない箇所があったので今回はuseDrop
のdrop()
とuseDrag
のend()
で並び替えるようにしました。
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 📄
必須項目でuseDrag
のtype
と合わせる必要がある。
useDrag
のtype
は文字列型かシンボル型、useDrop
のaccept
は文字列型かシンボル型か配列型にする必要がありとのこと。
useDrop
のaccept
は配列型を受け入れるのは多分ここが理由そうでした。
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 📄
オブジェクトを返した場合、それがドロップ結果となるのでuseDrag
のend()
からmonitor.getDropResult()
で取れるようになるとのこと。
今回だとmonitor.getDropResult()
でこのようなオブジェクトが返ってきます。
{
dropEffect: "move",
fieldData: {name: 0, key: 0},
indexKey: 1,
}
-
useDrop
のcanDrop()
でfalse
を返すとdrop()
は呼び出されず、useDrag
のend()
の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 🏃
必須項目でuseDrop
のaccept
と合わせる必要がある。
先に書いた通り文字列型かシンボル型にする。
item 🏃
必須項目でオブジェクトか関数を指定するとのことなのですがこれが最初に一番混乱しました。
公式のチェスのコード見るとitem
ないんですよね、、、
神隠し?上級者にしか見えないどこかにあるのか私の勘違いなのかよくわかりません( ´~` )
useDrop
がわかるドラッグしているものの情報で複雑な参照ではなく最小限で渡してあげると良いとのことでuseDrop
のdrop: (item, monitor)
のitem
がこのitem
とイコールになりました。
脱線するのですが公式のナイトをドラッグすると突然のリアルな馬でした🐴 古のニコ動感
end 🏃
useDropのdrop部分にある事とほぼ一緒なのですがuseDrop
のdrop()
の内容が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