React DnDでフレキシブルにリサイズ・移動可能なboxを実装する
経緯
Drag & Drop でシームレスに移動することができて、なおかつリサイズ可能な box を実装する必要があったのですが、意外と情報がなかったので備忘録的にまとめました。
同じような悩みを抱えている人の参考になれば幸いです。
サンプル
最終的な動き
使用ライブラリ
React の基本的なパッケージ以外で使用しているライブラリは以下の 2 つです。
- React DnD: https://github.com/react-dnd/react-dnd
React で D&D を提供する - re-resizable: https://github.com/bokuweb/re-resizable
リサイズ可能なコンポーネントを提供する
説明
思ったより長くなってしまったのでサンプルを見て動かした方が早いかもしれません。実装の流れ・注意点を知りたい人は以下に書きました。
シンプルな D&D
ブランチ
drop-box
実際の動き
実装方法
準備
空ページの初期状態が start ブランチになっていますので、実装の流れをなぞる場合はここから始めるといいでしょう。create-react-app などで React プロジェクトを新規作成しても構いません。
まずは今回使用するパッケージを追加します。TypeScript を使用していますので@types
パッケージも追加します。
npm i re-resizable react-dnd react-dnd-html5-backend @types/react-dnd @types/react-dnd-html5-backend
DnD Provider の設定
パッケージが追加できたら基本的な動きを実装します。トップに配置されている box を下のドロップ可能エリア内に持ってこれるようにします。
まずはコンポーネント内で React DnD を使用できるよう設定を行います。D&D を使用するコンポーネントをDnD Provider
でくるんでください。backend
にはHTML5Backend
を渡します。
コンポーネント用フォルダ作成
src/pages/flexible-box
配下に今回実装するコンポーネントを配置することとします。
フォルダ作成後、index.tsx を配置します。
関数定義
描画部分と純粋なロジックを分離したいのでsrc/pure-functions
配下にただの関数を置くことにします。
indexGenerator
は以下のように使用します。簡易的な状態管理ができます。
const getIndex = indexGenerator(0); // 関数が返ってくる
console.log(getIndex()); // 0
console.log(getIndex()); // 1
console.log(getIndex()); // 2
const getNo = indexGenerator(1); // 関数が新規生成されて返ってくる
console.log(getNo()); // 1
console.log(getNo()); // 2
console.log(getNo()); // 3
定数を返す用の関数もつくっておきます。直接変数を export するより安全です。(呼び出されるたびにインスタンスが変わるため)
タイプ定義
タイプ定義も別ファイルに分けておきます。
flexible-box コンポーネント
Page.tsx
を作成します。トップに配置される box と下部のドロップ部分のコンポーネント呼び出しを書きます。
ドラッグ可能な box のコンポーネントを作成します。useDrag の返り値の 2 番目をドラッグ対象 div の ref に設定します。type には識別名を設定します。useDrag と useDrop で識別名を合わせることで連携ができるようになります。item にはやり取りしたいデータを書きます。
useDrag の第 2 引数は useEffect などと同じく依存する変数を列挙します。漏れがあるとデータ不整合になるため気をつけましょう。
ドロップ対象のコンポーネントを作成します。useDrop の返り値の 2 番目をドロップ対象 div の ref に設定します。今回は ref を他の処理にも使いたかったため、直接渡すのではなく明示的に実行しています。
第 2 引数は useDrag と同じく依存する変数の配列です。
drop(item: DraggableBox, monitor)
はドロップしたときに呼ばれる関数です。第 1 引数には useDrag で設定したデータが入っており、第 2 引数の monitor には D&D における現在の状態を取得できるメソッドが収められています。
drop が呼ばれたときに描画用配列にデータが追加されるようにします。
リサイズ可能にする
ブランチ
resize-box
実装方法
ドロップできるようになったたので次はリサイズを実装します。
div の代わりに re-reizable を使えばいいのですが、ただ置き換えただけだと以下のような動きになります。
top と left が固定されているため領域が右下に広がっていきます。これを修正するにはリサイズ時に座標を再計算するようにします。
リサイズ時に注意する点は以下です。
- 座標は px ではなく%で計算する
ブラウザサイズを変更したときもレイアウトが崩れないようにするため。完全な固定表示なら px で OK。 - サイズがマイナスにならないように minWidth, minHeight を設定する
re-resizable はデフォルト設定だと最小値が設定されていないため設定しておきましょう - 領域外にはみ出さないように maxWidth, maxHeight を計算する
re-resizable はデフォルト設定だとどこまでも大きくなるため max を設定します。このときの値は領域をはみ出さないギリギリの値にします。これは計算によって求められます。 - リサイズ時に top, left の値を再計算する
リサイズに表示がずれないように top, left の座標値を再計算します。 - リサイズ時の計算に使用するため、リサイズ開始時にリサイズ前のサイズ・座標を保持しておく
リサイズ開始時の値を保持しておき、計算しやすくします。
ドロップ時の処理を見てみましょう。top, left, width, height を px から%に直しているのがわかると思います。
MovableBox.tsx
の中にリサイズ時の処理が書いてあります。やっていることの意味は以下の通りです。
onResizeStart
: リサイズ開始時に実行される。初期座標・サイズを initialBounds に保存。
onResizeEnd
: リサイズ終了時に実行される。initialBounds を初期化。
onResize
: リサイズ中に連続して実行される。拡張されている方向を取得し座標・領域からはみださないサイズを計算する。
その他の変更点に関してはブランチを見てください。
ドラッグ中のプレビュー
ブランチ
drag-preview
実装方法
React DnD はデフォルトでも半透明のプレビューが出てきますが、表示をカスタムしたい場合はドラッグ中の状態を取得し自前で描画する必要があります。
MovableBox.tsx
の以下の箇所でデフォルトプレビューの表示を消しています。任意の画像に変更することが出来ますが、今回は空の画像を使ってドラッグ中の表示を消します。
また、元の位置に存在する表示も消すため、ドラッグ中は透明度を設定して見えなくします。DOM から消してしまうと React DnD がうまく動かないことがあるため見えなくするという対応を取ります。collect には関数を設定することができ、返り値が useDrag の第 1 引数になります。
Container.tsx
でuseDragLayer
を使ってドラッグ中の状態を取得します。item がドラッグ中のデータ(useDrag で設定されたもの)、itemType が識別名、differenceOffset が移動した分の座標、isDragging はドラッグ中か否かです。
これらのデータからドラッグ中のプレビューを描画します。
MovableBoxPreview.tsx
がプレビュー表示用のコンポーネントです。注意点はpointerEvents: "none"
を設定している点で、これがないとドラッグ中のイベント処理が邪魔されてうまくいきません。
移動可能にする
ブランチ
move-box
実装方法
プレビューまでできましたが、終了時の処理を設定していないため元の位置に戻ってきてしまいます。
ドロップ時の処理を書いてもいいのですが、今回はカーソルが領域からはみ出た場合、端っこに移動するようにしたいです。この場合はドロップ時の処理の代わりにドラッグ終了時の処理を書きます。
onEndDrag
は serialNo に一致する box の座標を書き換えるメソッドです。
その他の変更点に関してはブランチを見てください。
レイアウト調整
ブランチ
main
実装方法
move-box からの変更点はレイアウト調整やアプリ名の変更など細々とした変更のみです。
まとめ
重要だと思ったポイント、実装上のつまりポイントをまとめました(多い…)。
- React DnD と他のライブラリを組み合わせることで複雑な UI を実現できる
- D&D を使用するコンポーネントを DnD Provider でくるむ
- ドラッグ中のカーソル変更は TouchBackend を使えばできる
- drag, drop の ref を div に設定する
- リサイズ時の座標を再計算することで左上にも box を拡大できる
- プレビューをカスタムするときはデフォルトのプレビュー表示を消し、DragLayer でプレビューを独自実装する
- カスタムプレビューには
pointer-events: none
を設定する - 識別名を変更することで複数種類の D&D を設定可能
- endDrag と drop の使い分け
- drag 対象にした div は DOM から消さず透明にする
Discussion