🌊

意図的でない車輪の再発明の精神的ダメージはでかいって話 - Draggable and Resizable UI with React

2023/04/24に公開

はじめに

車輪の再発明って楽しいですよね。

しかし業務中は発明された車輪を用いて楽をしたいです。
目的を果たす車輪がないと判断したときは自分で発明(開発)するしかないですが、再発明をしてしまった後にちょうどよく「発明されていた車輪」を発見すると

(再発明の労力)-(発明されていた車輪での労力)=(精神的なダメージ)

を喰らいます。

欲しかった車輪の要件は以下の通りです。

  • 滑らかにドリフトができて(Draggable)
  • 外径が可変(Resizable)

PowerPointやKeynoteなどのプレゼンツールの要素をイメージするとわかりやすいと思います。
滑らかにというのはよくある表の入れ替えなどではなく1px単位でドラッグをしたいことを指します。
これをReactで実装したい。

車輪をドリフト(Draggable)させて無理やり外径を可変(Resizable)にする再発明をしてしまったので、実際に「再発明した車輪」を紹介した後に、要件を満たす完璧な「発明されていた車輪」を紹介しようと思います。

コードはこちらにあります。
https://github.com/kthatoto/resizable-draggable-ui-react

TL;DR

ドラッグの実装だけしたいならreact-draggableがおすすめ
ドラッグ&リサイズの実装をしたいならreact-rndがおすすめ

「再発明した車輪」

https://github.com/react-grid-layout/react-draggable

$ npm install react-draggable
or
$ yarn add react-draggable

まずはドリフトできる(Draggable)だけの車輪です。

import Draggable from "react-draggable";

export default () => {
  return (
    <div style={{ width: "100%", height: "100vh" }}>
      <Draggable>
        <div style={{ background: "gray", width: 100, height: 100 }}></div>
      </Draggable>
    </div>
  );
};

これだけでドラッグできるコンポーネントができます。ドラッグだけをしたいのであればこれで十分なのですが、大きさ(width,height)を変えたいとなるともう少し手を加える必要があります。

この四角の各辺と各点に入れ子で<Draggable>を置いてこの要素のドラッグ差分を元の四角のwidth,heightに足します。
全部で8箇所リサイズハンドルを置く場所がありますが便宜上、右下の点のみで水平垂直両方向のリサイズをできるようにします。

できたものがこちらです。

import Draggable, { DraggableData, DraggableEvent } from "react-draggable";
import { useCallback, useRef, useState } from "react";

const MIN_WIDTH = 50;
const MIN_HEIGHT = 50;

export default () => {
  const [size, setSize] = useState({ width: 100, height: 100 });
  const [resizingSize, setResizingSize] = useState(size);

  const draggableNodeRef = useRef(null);
  const resizeNodeRef = useRef(null);

  const onStartResize = useCallback((e: DraggableEvent) => {
    e.stopPropagation();
  }, []);
  const onStopResize = useCallback(() => {
    setSize(resizingSize);
  }, [resizingSize]);

  const onResize = useCallback((_e: DraggableEvent, data: DraggableData) => {
    setResizingSize({
      width: Math.max(MIN_WIDTH, size.width + data.x),
      height: Math.max(MIN_HEIGHT, size.height + data.y),
    });
  }, [size]);

  return (
    <div style={{ width: "100%", height: "100vh" }}>
      <Draggable nodeRef={draggableNodeRef}>
        <div
          ref={draggableNodeRef}
          style={{ background: "gray", position: "relative", ...resizingSize }}
        >
          <Draggable
            nodeRef={resizeNodeRef}
            onDrag={onResize}
            onStart={onStartResize}
            onStop={onStopResize}
            position={{ x: 0, y: 0 }}
            axis="none"
          >
            <div
              ref={resizeNodeRef}
              style={{
                height: 16,
                width: 16,
                position: "absolute",
                bottom: 0,
                right: 0,
                cursor: "nwse-resize",
                backgroundColor: "blue",
              }}
            ></div>
          </Draggable>
        </div>
      </Draggable>
    </div>
  );
};

少し複雑になりましたがポイントを解説します。

1. e.stopPropagation() でイベントの伝播を止める

const onStartResize = useCallback((e: DraggableEvent) => {
  e.stopPropagation();
}, []);

<Draggable>の中の<Draggable>をドラッグするときにイベントの伝播の制御をしないままだと子要素のドラッグ分そのまま親要素までドラッグしてしまうので必要です。

2. リサイズロジック

const onResize = useCallback((_e: DraggableEvent, data: DraggableData) => {
  setResizingSize({
    width: Math.max(MIN_WIDTH, size.width + data.x),
    height: Math.max(MIN_HEIGHT, size.height + data.y),
  });
}, [size]);

右下の青い四角をドラッグすると連続して呼び出される部分です。
DraggableDataは以下のようなものです。

interface DraggableData {
  node: HTMLElement; // ドラッグした要素
  x: number; // ドラッグ開始位置からの水平方向の差分
  y: number; // ドラッグ開始位置からの垂直方向の差分
  deltaX: number; // 前イベントからの水平方向の差分
  deltaY: number; // 前イベントからの垂直方向の差分
  lastX: number; // 前イベントの`x`
  lastY: number; // 前イベントの`y`
}

色々あるので色々できそうですね。
今回はx,yを使って初期位置からの差分を取り、ドラッグ開始前のサイズ(size)と合わせてドラッグ中のサイズを計算しています。
またMIN_WIDTH,MIN_HEIGHTを用いて一定以上小さくならないようにもなっています。
ドラッグが終わったら

const onStopResize = useCallback(() => {
  setSize(resizingSize);
}, [resizingSize]);

をしてsizeresizingSizeをコミットするようにしています。
sizeのコミットタイミングがドラッグ終了であること、つまりドラッグ開始時のサイズであることにより
size.width(ドラッグ開始時の幅) + data.x(ドラッグ開始位置からの水平方向の差分)としてドラッグ中の幅が計算できます。

3. その他工夫

右下の青い四角の部分です。

<Draggable
  nodeRef={resizeNodeRef}
  onDrag={onResize}
  onStart={onStartResize}
  onStop={onStopResize}
  position={{ x: 0, y: 0 }} // 工夫1
  axis="none"               // 工夫2
>

工夫1

position={{ x: 0, y: 0 }}に関してはドラッグ開始時の初期値を渡しています。これがないと前回のドラッグした分がそのまま残ってしまうので2回目以降のドラッグでカーソルと青い四角が前回ドラッグ分ずれてしまいます。

工夫2

axios="none"に関してはドラッグする方向を表していますが、青い四角の要素の親要素(灰色の四角)のサイズが変わることによりカーソルと青い四角の位置が合うことになるのでこれ自体はドラッグによって動く必要がないため"none"となっています。


以上「再発明した車輪」の実装の紹介でした。
では次にこの大変な実装をした後に見つけた「発明されていた車輪」による実装を紹介します。

「発明されていた車輪」

https://github.com/bokuweb/react-rnd

$ npm install react-rnd
or
$ yarn add react-rnd
import { Rnd } from "react-rnd";

export default () => {
  return (
    <div style={{ width: "100%", height: "100vh" }}>
      <Rnd
        default={{ x: 0, y: 0, width: 100, height: 100 }}
        minWidth={50}
        maxHeight={50}
        style={{ backgroundColor: "gray" }}
        resizeHandleStyles={{
          bottomRight: { backgroundColor: "blue" }
        }}
      />
    </div>
  );
};

これだけです。

自分で書く部分ではReact Hooksの機能をひとつも使わず状態管理を全くせずに完璧に要件を満たしています。
なんなら全方向8箇所ともリサイズハンドルがあるので上位互換です。
(右下じゃない箇所のリサイズはwidthを変えた上でposition.xも変える必要があるからものすごくめんどくさそう)

おわりに

ということで意図的でない車輪の再発明の精神的ダメージ(「再発明の労力」と「発明されていた車輪での労力」の差)がでかいことがおわかりいただけたと思います。
意図して個人的に車輪を再発明するのは楽しいんですけどね。

欲しい車輪がすでに誰かによって発明されていないかよく調査しましょう。
みなさんも良き再発明ライフを!


株式会社クロスビットでは、デスクレスワーカーのためのHR管理プラットフォームを開発しています。
一緒に開発を行ってくれる各ポジションのエンジニアを募集中です。
https://x-bit.co.jp/recruit/
https://herp.careers/v1/xbit
https://note.com/xbit_recruit

クロスビットテックブログ

Discussion