カテゴリー: FrontEnd

React Konvaで状態管理されたCanvasを描画してみた

はじめに

着せ替えメーカーが流行っているらしく、自分でも作ってみたくなったので、技術調査をしました。着せ替えメーカーとは、用意されたキャラクターのパーツ(顔・目・髪・口など)を組み合わせたり、位置を調整したりしてオリジナルのキャラクターを作ることのできるサービスです。
スマホでの利用も考えていたので、異なる画面サイズでも、同じ比率でパーツを描画したり、位置調整をするか、という点が技術課題でした。この点を調べていくうちに、Canvasであれば、描画領域をスケールさせることで、Canvas内部の座標系を維持したまま拡大・縮小できることが分かったので、今回はCanvasと、CanvasをReactで使いやすくするためのReact Konvaについて調査しました。

Canvasとは?

Canvasとは、Web上で2Dグラフィックスを描画するためのAPIです。一番簡単な例だと、このようにCanvasに描画することができます。

<!DOCTYPE html>
<html lang="en">
  <body>
    <canvas id="canvas" width="300" height="300"></canvas>
    <script>
      const canvas = document.getElementById("canvas");
      const ctx = canvas.getContext("2d");

      ctx.fillStyle = "green";
      ctx.fillRect(10, 10, 150, 100);
    </script>
  </body>
</html>

以下のように、緑色の図形が描画されます。

このように、CanvasのAPI自体はすぐに試すことができるのですが、命令型のAPIとなっているので、状態の変化に応じてCanvasの内容を操作するようなアプリケーションを操作する場合、複雑な処理になることが想像できると思います。
また、Reactを使ってアプリケーションを構築したいため、相性の良いライブラリを探してみたところ、react-konvaが便利そうだったので、試してみたいと思います。

React Konvaとは

まず、Konvaは、Canvasのラッパーライブラリです。Canvasの命令型APIをラップし、Konva独自の「レイヤー」オブジェクト上に、図形などを追加したり、イベントトリガーを追加していくことで、Canavsへの描画などを行なってくれます。詳しくはこちらのサンプルを見てください。
React Konvaは、KonvaをReactで使いやすくするライブラリで、React Componentを宣言するようにCanvasを描画することができます。
以下が簡単な例です。(React Konvaのサンプルコードです。)

Stageコンポーネントから始まり、直下にLayer、そしてその配下に描画したいオブジェクトを並べていくと、実際にCanvasに描画されます。また、通常のReactコンポーネントと同じような使い勝手なので、状態管理してCanvasに描画するには、通常のDOMをレンダリングする場合と同じように、mapを使って描画していきます。
それでは今回は、konva reactを使って簡単な着せ替えアプリを作ってみたいと思います。

着せ替えアプリっぽいサンプルを作成

サンプルを作成しました。完成形はこちらです。

こちらが全体のソースコードです

import { createRef, useEffect, useRef, useState } from "react";
import eye1 from "./assets/eye1.png";
import face1 from "./assets/face1.png";
import glasses1 from "./assets/glasses1.png";
import mouth1 from "./assets/mouth1.png";
import nose1 from "./assets/nose1.png";
import "./App.css";
import { Image, Layer, Rect, Stage } from "react-konva";
import useImage from "use-image";
import { v4 as uuidv4 } from "uuid";
import { useWindowSize } from "react-use";
import Konva from "konva";

// 選択可能なカテゴリ
const Category = {
  Face: "face",
  Eye: "eye",
  Glasses: "glasses",
  Nose: "nose",
  Mouth: "mouth",
} as const;

type Category = typeof Category[keyof typeof Category];

// 着せ替えのパーツの型
type Part = {
  id: string;
  imagePath: string;
  category: Category;
  x: number;
  y: number;
};

// 着せ替えパーツ定義
const PARTS = [
  {
    id: uuidv4(),
    imagePath: face1,
    category: Category.Face,
    x: 60,
    y: 0,
  },
  {
    id: uuidv4(),
    imagePath: eye1,
    category: Category.Eye,
    x: 92,
    y: 112,
  },
  {
    id: uuidv4(),
    imagePath: mouth1,
    category: Category.Mouth,
    x: 104,
    y: 256,
  },
  {
    id: uuidv4(),
    imagePath: nose1,
    category: Category.Nose,
    x: 168,
    y: 184,
  },
  {
    id: uuidv4(),
    imagePath: glasses1,
    category: Category.Glasses,
    x: 40,
    y: 72,
  },
] as Part[];

// Stage(Canvas)内のサイズ
const BASE_SIZE = 410;

const PartImage = ({ part }: { part: Part }) => {
  const [image] = useImage(part.imagePath);
  return <Image image={image} x={part.x} y={part.y} />;
};

function App() {
  const [stageParts, setStageParts] = useState<Part[]>([
    { ...PARTS[0] },
    { ...PARTS[1] },
    { ...PARTS[2] },
    { ...PARTS[3] },
  ]);
  const [selectedPartId, setSelectedPartId] = useState<string | null>(null);
  const { width } = useWindowSize();
  const [stageSize, setStageSize] = useState(0);
  const ref = createRef<HTMLDivElement>();
  const stageRef = useRef<Konva.Stage>(null);

  // パーツを移動ボタンで移動させる
  const translate = (x: number, y: number) => {
    const p = stageParts.find((p) => p.id === selectedPartId);
    if (!p) {
      return;
    }

    p.x += x * 4;
    p.y += y * 4;
    setStageParts(stageParts.map((pp) => (pp.id === selectedPartId ? p : pp)));
  };

  useEffect(() => {
    setStageSize(ref.current?.clientWidth || 0);
  }, [width]);

  const scale = stageSize / BASE_SIZE;

  // 完成した着せ替えのダウンロード
  const download = () => {
    if (!stageRef.current) {
      return;
    }
    const link = document.createElement("a");
    link.download = "stage.png";
    link.href = stageRef.current.toDataURL();
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
  };

  return (
    <div
      style={{ width: "100%", maxWidth: "640px", margin: "0 auto" }}
      ref={ref}
    >
      {/*着せ替え表示*/}
      <Stage
        width={stageSize}
        height={stageSize}
        scaleX={scale}
        scaleY={scale}
        style={{ position: "sticky", top: "8px" }}
        ref={stageRef}
      >
        <Layer key="backgroud">
          <Rect width={BASE_SIZE} height={BASE_SIZE} fill="white" />
          {Object.values(Category).map((c) => {
            return stageParts
              .filter((p) => p.category === c)
              .map((p, i) => <PartImage part={p} key={i} />);
          })}
        </Layer>
      </Stage>
      {/*パーツの移動・削除ボタン、ダウンロードボタン*/}
      <div style={{ display: "flex" }}>
        <table border={1}>
          <tbody>
            <tr>
              <td></td>
              <td>
                <button onClick={() => translate(0, -1)}>↑</button>
              </td>
              <td></td>
            </tr>
            <tr>
              <td>
                <button onClick={() => translate(-1, 0)}>←</button>
              </td>
              <td></td>
              <td>
                <button onClick={() => translate(1, 0)}>→</button>
              </td>
            </tr>
            <tr>
              <td></td>
              <td>
                <button onClick={() => translate(0, 1)}>↓</button>
              </td>
              <td></td>
            </tr>
          </tbody>
        </table>
        <button
          onClick={() =>
            setStageParts((prev) => prev.filter((p) => p.id !== selectedPartId))
          }
        >
          選択中のパーツを消す
        </button>
        <button onClick={download}>ダウンロード</button>
      </div>
      {/*パーツ選択エリア*/}
      <div>
        {Object.values(Category).map((c) => (
          <div key={c}>
            <h2>{c}</h2>
            {PARTS.filter((p) => p.category === c).map((p, i) => (
              <img
                key={i}
                style={{
                  height: "50px",
                  background:
                    p.id === selectedPartId
                      ? "orange"
                      : stageParts.some((pp) => pp.id === p.id)
                      ? "pink"
                      : undefined,
                }}
                src={p.imagePath}
                onClick={() => {
                  if (stageParts.some((pp) => pp.category === p.category)) {
                    setStageParts(
                      stageParts.map((pp) =>
                        pp.category === p.category ? { ...p } : pp
                      )
                    );
                  } else {
                    setStageParts([...stageParts, { ...p }]);
                  }
                  setSelectedPartId(p.id);
                }}
              />
            ))}
          </div>
        ))}
      </div>
    </div>
  );
}

export default App;

React Konvaの導入

yarnでインストールします。

$ yarn add react-konva konva use-image

react-konvakonvaが最低限必要なパッケージです。 use-imageはURLから画像ファイルを読み、React Konvaの <Image />コンポーネントに画像を読ませるためのhookです。

画像の描画

Canvasで画像を描画するには、React Konvaの <Image image={img} />コンポーネントを使用するのですが、propsに渡す画像は、 CanvasImageSourceという型になっています。

type CanvasImageSource = HTMLOrSVGImageElement | HTMLVideoElement | HTMLCanvasElement | ImageBitmap;

これらの型が意味するところはMDNで解説されているので詳しくは省略しますが、Canvas自体が画像を描画するには CanvasImageSource 型が必要となるため、React Konvaでもこの型で渡す必要があるのです。
今回はPNG画像を表示するのですが、その場合はimg要素に画像を読み込ませ、そのrefをImageコンポーネントに渡す必要があります。その実装の手間を省くため、konvajsが直々に公開している use-imagehookです。

このhookはシンプルなソースコードなので一読して欲しいのですが、imgタグを生成し、そのrefを返しています。ただ、エラーハンドリングもやってくれているので、おそらくこのhookを使った方が、楽に実装できるのではないかなと思います。
今回は、 PartImageというコンポーネントを作り、ここで useImagehookを使って画像を取得し、 <Image />コンポーネントを返しています。
以下は、先ほどのコードで実際に使っている部分のコードの切り抜きです。

const PartImage = ({ part }: { part: Part }) => {
  const [image] = useImage(part.imagePath);
  return <Image image={image} x={part.x} y={part.y} />;
};

function App() {
  // 省略

  return (
    <div>
      <Stage>
        <Layer key="backgroud">
          <Rect width={BASE_SIZE} height={BASE_SIZE} fill="white" />
          {Object.values(Category).map((c) => {
            return stageParts
              .filter((p) => p.category === c)
              .map((p, i) => <PartImage part={p} key={i} />); // PartImageで画像描画
          })}
        </Layer>
      </Stage>
    </div>
  );
}

 

stateによるCanvas描画

このサンプルでは stagePartsというステートがCanvasに描画すべき値を持っているので、このstateの変化に応じてStageの内容を書き換えています。以下がその部分です。

      <Stage
        width={stageSize}
        height={stageSize}
        scaleX={scale}
        scaleY={scale}
        style={{ position: "sticky", top: "8px" }}
      >
        <Layer key="backgroud">
          <Rect width={BASE_SIZE} height={BASE_SIZE} fill="white" />
          {Object.values(Category).map((c) => {
            return stageParts
              .filter((p) => p.category === c)
              .map((p, i) => <PartImage part={p} key={i} />);
          })}
        </Layer>
      </Stage>

stateをmapして、React Konvaで用意されたコンポーネントをを返していくだけで、Canvasに描画されていきます。通常のDOMと同じように、stateが変更されれば、Canvas内の描画も更新されるようになっているため、Reactに慣れているととても分かりやすいインターフェイスです。

レスポンスシブ対応

Stageのpropsには、widthとheightの他に、scaleX、scaleYも指定することができます。このスケールを設定することで、Canvas内を拡大・縮小することができます。例えば、次のようにスケールを0.5にすると、Canvas内は半分に縮小されて表示されるため、縦横ともに、実際の倍の1000ピクセル分の領域まで描画することができます。

<Stage width={500} height={500} scaleX={0.5} scaleY={0.5}>
  {/*このRectははみ出すことなくピッタリ描画される*/}
  <Rect width={1000} height={1000} fill="red" />  
</Stage>

この仕組みを使って、レスポンシブ対応をしてみました。その部分に関連するコードです。

// Stage内は常に410 x 410とする
const BASE_SIZE = 410;

const PartImage = ({ part }: { part: Part }) => {
  const [image] = useImage(part.imagePath);
  return <Image image={image} x={part.x} y={part.y} />;
};

function App() {
  const { width } = useWindowSize();
  const [stageSize, setStageSize] = useState(0);
  const ref = createRef<HTMLDivElement>();

  useEffect(() => {
    // ウインドウサイズが変更されたら、stageの親divのwidthと同じサイズにする
    setStageSize(ref.current?.clientWidth || 0);
  }, [width]);

  // Stageのscaleを求める
  const scale = stageSize / BASE_SIZE;

  return (
    {/*親divは、640px以下で縮む*/}
    <div
      style={{ width: "100%", maxWidth: "640px", margin: "0 auto" }}
      ref={ref}
    >
      <Stage
        width={stageSize}
        height={stageSize}
        scaleX={scale}
        scaleY={scale}
      >
         {/*省略*/} 
      </Stage>
    </div>
  );
}

export default App;

ウインドウサイズの変更を契機に、親divのサイズを取得し setStageSize()でセットします。このサイズは直接Stageのwidthとheightに入ります。そして、scaleは stageSize / BASE_SIZEで求めます。これで、Stageのサイズが変化しても、Stage内はそのまま拡大・縮小されて表示できるようになりました。

画像のエクスポート

Stageの画像をエクスポートするには、Stageのrefを取得し、 toDataURL()を呼び出します。これはCanvasの関数で、Canvasに描画されている内容を画像化したData URIを取得することができます。
デフォルトではPNG画像が取得できるため、後はダウンロードしたり、どこかへアップロードしたりすることができます。今回は、ローカルにダウンロードするように実装しました。こちらが該当部分を切り抜いたソースです。

function App() {
  const stageRef = useRef<Konva.Stage>(null);

  const download = () => {
    if (!stageRef.current) {
      return;
    }
    const link = document.createElement("a");
    link.download = "stage.png";
    link.href = stageRef.current.toDataURL();
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
  };

  return (
    <div
      style={{ width: "100%", maxWidth: "640px", margin: "0 auto" }}
      ref={ref}
    >
      <Stage
        ref={stageRef}
      >
        {/*省略*/}
      </Stage>
        {/*省略*/}
      <button onClick={download}>ダウンロード</button>
    </div>
  );
}

export default App;

さいごに

React Konvaは、Reactの状態管理の仕組みと親和性が高く、アプリケーションに組み込みやすいことが分かりました。今後はReact Konvaが内部的にどのような仕組みCanvasに描画しているか、またそのレンダリングコストについても調査したいと思います。

おすすめ書籍

カイザー

シェア
執筆者:
カイザー

最近の投稿

Goの抽象構文木でコードを解析する

はじめに Goでアプリケーショ…

3日 前

フロントエンドで動画デコレーション&レンダリング

はじめに 今回は、以下のように…

1か月 前

Goのクエリビルダー goqu を使ってみる

はじめに 最近携わっているとあ…

1か月 前

【Xcode15】プライバシーマニフェスト対応に備えて

はじめに こんにちは、suzu…

2か月 前