🍎

react-konvaでピンチインピンチアウト

2024/04/19に公開

まず

Konvaの公式ドキュメントにピンチインピンチアウトの実装方法は記載されています。
https://konvajs.org/docs/sandbox/Multi-touch_Scale_Stage.html#How-to-enable-pan-and-pinch-zoom-for-canvas-stage

ただ、これはscriptタグに内容を書いているのでreact konvaで実装するには少し改変が必要になります。
今回はそれを簡潔に書いていきます。

コードの解説

konvaの公式ドキュメントからコピペしたコードですが、軽く説明をします。
コピペしただけなので内容は流していただいて構いません。

<script>
      // by default Konva prevent some events when node is dragging
      // it improve the performance and work well for 95% of cases
      // we need to enable all events on Konva, even when we are dragging a node
      // so it triggers touchmove correctly
      Konva.hitOnDragEnabled = true;

      var width = window.innerWidth;
      var height = window.innerHeight;

      var stage = new Konva.Stage({
        container: 'container',
        width: width,
        height: height,
        draggable: true,
      });

      var layer = new Konva.Layer();

      var triangle = new Konva.RegularPolygon({
        x: 190,
        y: stage.height() / 2,
        sides: 3,
        radius: 80,
        fill: 'green',
        stroke: 'black',
        strokeWidth: 4,
      });

      var circle = new Konva.Circle({
        x: 380,
        y: stage.height() / 2,
        radius: 70,
        fill: 'red',
        stroke: 'black',
        strokeWidth: 4,
      });

      function getDistance(p1, p2) {
        return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
      }

      function getCenter(p1, p2) {
        return {
          x: (p1.x + p2.x) / 2,
          y: (p1.y + p2.y) / 2,
        };
      }
      var lastCenter = null;
      var lastDist = 0;
      var dragStopped = false;

      stage.on('touchmove', function (e) {
        e.evt.preventDefault();
        var touch1 = e.evt.touches[0];
        var touch2 = e.evt.touches[1];

        // we need to restore dragging, if it was cancelled by multi-touch
        if (touch1 && !touch2 && !stage.isDragging() && dragStopped) {
          stage.startDrag();
          dragStopped = false;
        }

        if (touch1 && touch2) {
          // if the stage was under Konva's drag&drop
          // we need to stop it, and implement our own pan logic with two pointers
          if (stage.isDragging()) {
            dragStopped = true;
            stage.stopDrag();
          }

          var p1 = {
            x: touch1.clientX,
            y: touch1.clientY,
          };
          var p2 = {
            x: touch2.clientX,
            y: touch2.clientY,
          };

          if (!lastCenter) {
            lastCenter = getCenter(p1, p2);
            return;
          }
          var newCenter = getCenter(p1, p2);

          var dist = getDistance(p1, p2);

          if (!lastDist) {
            lastDist = dist;
          }

          // local coordinates of center point
          var pointTo = {
            x: (newCenter.x - stage.x()) / stage.scaleX(),
            y: (newCenter.y - stage.y()) / stage.scaleX(),
          };

          var scale = stage.scaleX() * (dist / lastDist);

          stage.scaleX(scale);
          stage.scaleY(scale);

          // calculate new position of the stage
          var dx = newCenter.x - lastCenter.x;
          var dy = newCenter.y - lastCenter.y;

          var newPos = {
            x: newCenter.x - pointTo.x * scale + dx,
            y: newCenter.y - pointTo.y * scale + dy,
          };

          stage.position(newPos);

          lastDist = dist;
          lastCenter = newCenter;
        }
      });

      stage.on('touchend', function (e) {
        lastDist = 0;
        lastCenter = null;
      });

      layer.add(triangle);
      layer.add(circle);
      stage.add(layer);
    </script>

軽く解説します。
stage.on('touchmove') はタッチ動作をした時に発火する関数で、それに対して
stage.on('touchend') は指を離した時に発火する関数です。
まずタッチ動作をした時に
if (touch1・・・)
で指が一つだけタッチされていた場合には単純なドラッグ動作を続行します。
if (touch1 && touch2) で二本指が置かれた時にドラッグを停止してピンチインピンチアウトを実行します。

stage.scaleX(scale)と、stage.scaleY(scale)で拡大縮小、
stage.position(newPos)で開始位置を変更します。
そこ以外のコードは基本的にその拡大縮小や開始位置の計算ロジック部分になります。

react konvaでの実装コード

ではここからがreact konvaでのコードになります。(実装したコードから必要箇所だけ抜いてきたのでこのままで動かないかもです。)

lastCenterやlastDistなどの変数を保持するのはuseRefを使っています、useStateだと再レンダリングが走って無限ループになってしまうので。

stage.onもuseRefを使ってDOM参照をしています。
ただこのままだとstage.onが一度しか走らないので、onDragMoveにuseStateの変数をセットしてドラッグする度にstage.onが実行されるようにしました。

if (scale < 4) と、if (newPos.x > -1000000000)を足しているのは、原因は不明ですがこれがないと値がinfinityになってしまうからです。
おそらくレンダリングのフレーム数が問題。代わりにsleep的なのを入れても良さそう。

import React, {
  useState,
  useRef,
} from "react";
import { Group, Layer, Rect, Stage, StageProps } from "react-konva";

export const Home = () => {
    Konva.hitOnDragEnabled = true;
  const stageRef = useRef();
  const StageCurrent = stageRef.current as StageProps | null;
  const lastCenter = useRef({ x: null, y: null });
  const lastDist = useRef(0);
  const [dragStopped, setDragStopped] = useState(false);
  const [dragFlag, setDragFlag] = useState(false);

  function getDistance(p1, p2) {
    return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
  }

  function getCenter(p1, p2) {
    return {
      x: (p1.x + p2.x) / 2,
      y: (p1.y + p2.y) / 2,
    };
  }

  if (StageCurrent) {
    StageCurrent.on("touchmove", function (e) {
      const stage = e.target.getStage();

      e.evt.preventDefault();
      const touch1 = e.evt.touches[0];
      const touch2 = e.evt.touches[1];

      if (touch1 && !touch2 && !stage.isDragging() && dragStopped) {
        stage.startDrag();
        setDragStopped(false);
      }

      if (touch1 && touch2) {
        if (stage.isDragging()) {
          setDragStopped(true);
          stage.stopDrag();
        }

        const p1 = {
          x: touch1.clientX,
          y: touch1.clientY,
        };
        const p2 = {
          x: touch2.clientX,
          y: touch2.clientY,
        };

        if (!lastCenter.current) {
          lastCenter.current = getCenter(p1, p2);
          return;
        }
        const newCenter = getCenter(p1, p2);
        const dist = getDistance(p1, p2);

        if (!lastDist) {
          lastDist.current = dist;
        }

        const pointTo = {
          x: (newCenter.x - stage.x()) / stage.scaleX(),
          y: (newCenter.y - stage.y()) / stage.scaleX(),
        };

        const scale = stage.scaleX() * (dist / lastDist.current);

        if (scale < 4) {
          stage.scaleX(scale);
          stage.scaleY(scale);
          scaledif.current = scale;
        }

        const dx = newCenter.x - lastCenter.current.x;
        const dy = newCenter.y - lastCenter.current.y;

        const newPos = {
          x: newCenter.x - pointTo.x * scale + dx,
          y: newCenter.y - pointTo.y * scale + dy,
        };
        message.current = `${newPos.x} : ${newPos.y}`;

        if (newPos.x > -1000000000 && scale < 4) {
          stage.position(newPos);
        }
        lastDist.current = dist;
        lastCenter.current = newCenter;
      }
    });
    StageCurrent.on("touchend", function (e) {
      lastDist.current = 0;
      lastCenter.current = null;
    });
  }

  return (
    <>
            <Stage
              width={window.innerWidth}
              height={window.innerHeight}
              draggable
              ref={stageRef}
              onDragMove={(e) => {
                setDragFlag(!dragflag);
              }}
            >
               <Layer>
                <Group scale={displayScale}>
                  <Circle
                    x={380}
                    y={300}
                    radius={70}
                    fill="red"
                    stroke={"black"}
                    strokeWidth={4}
                  />
                </Group>
              </Layer>
            </Stage>
          </>
        )}
    </>
  );
};

以上です。
今回の実装でuseRefとuseStateの理解が深まりました。

onDragMoveにstage.onの中身をそのまま書いてもいいんじゃない?と最初思ったのですが、二本指の時ドラッグ動作を停止する必要があって、そこで処理が止まってしまい、この形に落ち着きました。

追加補足

StageコンポーネントにonTouchEndとonTouchMoveがありました。これを使えばそのまま処理を書けますね。

NCDCエンジニアブログ

Discussion