🧪

Flutterでマウスで自由に図形を動かすコードを書いてみた

2022/07/17に公開

はじめに

VisioとかDraw.ioみたいな図形描画ツール、というかシステム構成図をメンテしやすいツールが欲しくてパッと見では気に入ったツールが無いので試しに作ってみる事にしました。
開発環境はせっかくなのでWebだけでは無くiPadとかタブレッドでも動くと便利そうなのでFlutterを使ってみる事にしました。Flutter触ってみたかったし描画ツールみたいなのだとJSとDOMで頑張って書くよりも素直そうなので。

というモチベーションですが今回は初歩の初歩という事でFlutterで図形を描画して、自由に動かすコードを書いてみました。ちょっと見様見真似で書いてみたので 「Dart/Flutter的にこの書き方はイマイチ」 みたいな部分があればコメント等を頂けると嬉しいです。

この記事では最終的には以下のデモサイトのようなものが作れます。また最初はFlutterの導入なので、すでに完了している人はスキップして本題の図形描画までジャンプしても良いと思います。
https://sandbox-svc-dev-8rra.web.app/#/

作成したコードは一応こちらにあります。
https://github.com/koduki/example_flutter_draw/tree/01_movable_diagram

Flutterの開発環境を構築

とりあえずFlutterの開発環境を構築します。多くのサンプルはその出自的にiOSやAndroid向けのものですが、私のメインのターゲットはWebなのでWebをベースに開発環境を作ります。
と言っても特に手順に変更は無いですが、ブラウザ周りの設定があるのでWindowsであればWSL上でやるより、PowerShellとかCMDで直に作業した方が少し楽だと思います。

インストール

まずは以下のようにインストールします。公式からダウンロードして好きな場所に配置します。私はC:\$USER\flutterに入れてパスを通しています。Flutterで利用されるプログラミング言語であるDartも併せてインストールされるので別途追加する必要はありません。
https://docs.flutter.dev/get-started/install/windows

flutter doctorで利用できるモジュールの状態をチェックします。IDEとしてはVS Codeが便利なので、こちらをインストールしてプラグインを入れるのがお勧めです。Androidのツールチェイン等はインストールされていませんが、Flutter自身やChromeにチェックが入っているので今回は問題ありません。doctorコマンドは中々便利なので他な環境にも欲しいですね。流石後発FW。

$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[] Flutter (Channel stable, 3.0.5, on Microsoft Windows [Version 10.0.22000.795], locale ja-JP)
[] Android toolchain - develop for Android devices
    ✗ Unable to locate Android SDK.
      Install Android Studio from: https://developer.android.com/studio/index.html
      On first launch it will assist you in installing the Android SDK components.
      (or visit https://flutter.dev/docs/get-started/install/windows#android-setup for detailed instructions).
      If the Android SDK has been installed to a custom location, please use
      `flutter config --android-sdk` to update to that location.

[] Chrome - develop for the web
[!] Visual Studio - develop for Windows (Visual Studio Community 2019 16.11.2)
    ✗ Visual Studio is missing necessary components. Please re-run the Visual Studio installer for the "Desktop
      development with C++" workload, and include these components:
        MSVC v142 - VS 2019 C++ x64/x86 build tools
         - If there are multiple build tool versions available, install the latest
        C++ CMake tools for Windows
        Windows 10 SDK
[!] Android Studio (not installed)
[] VS Code (version 1.69.1)
[] Connected device (3 available)
[] HTTP Host Availability

iOSやAndroid向けの環境だともうひと手間ほど必要になりますが、Web向けだととても準備が簡単ですね。

プロジェクト作成 & Hello World

準備が出来たのでプロジェクトの作成と動作確認を行います。

$ flutter create --platforms web example_flutter_draw
$ cd .\example_flutter_draw\
$ flutter run
Chrome (web) • chrome • web-javascript • Google Chrome 103.0.5060.114
Edge (web)   • edge   • web-javascript • Microsoft Edge 103.0.1264.62
[1]: Chrome (chrome)
[2]: Edge (edge)
Please choose one (To quit, press "q/Q"): 2

こんな感じで実行するとデフォルトで作られたカウンターアプリが起動します。

--platformsでWebを指定しないとデフォルトがAndroidやWindowsになっていて意図しない動作をするかもなので注意をしてください。
VS Codeから実行する場合は 「実行 -> デバックの開始」 を選ぶと良いです。同じくプラットフォームがChromeないしはEdgeなどのブラウザになってることを確認しましょう。

なお、今回はFlutterやDartの基本的な書き方には言及しないですが、以下の記事を参考に公式サイトを見るのをお勧めします。Tutorialを順に見てると地味にハマるので私も下記の記事と同じ結論になりました><
https://zenn.dev/st43/articles/1b56e54bc138ac#個人的に推奨する導線

Flutterで図形を描画する

さて、それでは本題の図形描画です。Flutterでは図形の描画はCustomPainterを使って行うそうです。まずはシンプルに四角や丸を描いてみます。

とりあえず以下のように画面の基本骨子を作ります。

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Draw Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const DrawPage(title: 'Flutter Draw Demo'),
    );
  }
}

class DrawPage extends StatefulWidget {
  const DrawPage({Key? key, required this.title}) : super(key: key);
  final String title;

  
  State<DrawPage> createState() => _DrawPageState();
}

続いて、以下が実際に描画をする部分です。今回はdrawCircleを使って円を描いています。childにCutomPaintを指定して、実際の描画はCustomPainterを継承したCirclePainterを使います。painterの部分を変更する事で様々な図形を描画する事が出来ます。

class _DrawPageState extends State<DrawPage> {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: CustomPaint(
          size: const Size(200, 200),
          painter: CirclePainter(),
        ),
      ),
    );
  }
}

class CirclePainter extends CustomPainter {
  
  void paint(Canvas canvas, Size size) {
    final paint = Paint();
    final double radius = size.width / 2;

    paint.color = Colors.blueAccent;
    canvas.drawCircle(Offset(radius, radius), radius, paint);
  }

  
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

他の図形も描いてみます。Centerの代わりにStackとPositionedを使い複数のWidgetを描けるようにしてみます。PositionedはCSSで言うposition: absoluteのように絶対座標で指定出来るコンテナです。先ほどのCirclePainterに加え、四角形を描画するRectPainterを追加しCanvas#drawCircleを実行しています。

class _DrawPageState extends State<DrawPage> {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Stack(children: <Widget>[
        Positioned(
            left: 60,
            top: 60,
            child: CustomPaint(
              size: const Size(200, 200),
              painter: CirclePainter(),
            )),
        Positioned(
            left: 300,
            top: 60,
            child: CustomPaint(
              size: const Size(200, 100),
              painter: RectPainter(),
            ))
      ]),
    );
  }
}

class CirclePainter extends CustomPainter {
  
  void paint(Canvas canvas, Size size) {
    final paint = Paint();
    final double radius = size.width / 2;

    paint.color = Colors.blueAccent;
    canvas.drawCircle(Offset(radius, radius), radius, paint);
  }

  
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

class RectPainter extends CustomPainter {
  
  void paint(Canvas canvas, Size size) {
    final paint = Paint();
    paint.color = Colors.green;
    canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint);
  }

  
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }}

GestureDetectorを使って図形を動かす

先ほど描いた図形をマウスのドラッグアンドドロップ等で動かすにはどうすれば良いでしょうか? 単純に考えるとマウスのドラッグイベントを捕まえてPositionedの座標を変更してやれば良さそうです。
FlutterでカスタムWdidgetとしてイベントを取るにはGestureDetectorを使う事で対応出来そうです。
https://zenn.dev/welchi/articles/flutter-custom-widget
GestureDetectorはタップやドラッグといった操作をイベントとして取得できるAPIで、PCからブラウザで操作する場合にはクリックイベント等が取得できます。今回はピンチ操作中/ドラッグ操作中を取得できるonPanUpdateを使いました。GestureDetectorに関しては以下のサイトも詳しいです。
https://qiita.com/kurun_pan/items/4d345075064a478a6b28

図形毎にOffsetをフィールド変数として定義してonPanUpdateで更新し、そちらをPositionedの座標に渡しています。onPanUpdateの中でState#setStateが実行されているのでWidgetのbuildメソッドが都度実行され値が反映されます。

class _DrawPageState extends State<DrawPage> {
  var circlePos = const Offset(60, 60);
  var rectPos = const Offset(300, 60);

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Stack(children: <Widget>[
        Positioned(
            left: circlePos.dx,
            top: circlePos.dy,
            child: GestureDetector(
                onPanUpdate: (DragUpdateDetails details) {
                  setState(() {
                    circlePos += details.delta;
                  });
                },
                child: CustomPaint(
                  size: const Size(200, 200),
                  painter: CirclePainter(),
                ))),
        Positioned(
            left: rectPos.dx,
            top: rectPos.dy,
            child: GestureDetector(
                onPanUpdate: (DragUpdateDetails details) {
                  setState(() {
                    rectPos += details.delta;
                  });
                },
                child: CustomPaint(
                  size: const Size(100, 100),
                  painter: RectPainter(),
                ))),
      ]),
    );
  }
}

class CirclePainter extends CustomPainter {
  
  void paint(Canvas canvas, Size size) {
    final paint = Paint();
    final double radius = size.width / 2;

    paint.color = Colors.blueAccent;
    canvas.drawCircle(Offset(radius, radius), radius, paint);
  }

  
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

class RectPainter extends CustomPainter {
  
  void paint(Canvas canvas, Size size) {
    final paint = Paint();
    paint.color = Colors.green;
    canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint);
  }

  
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }}

とりあえず、目的の 「図形をマウスで動かす」 という事は達成できました。

図形毎にコンポーネント化してみる

さて、目的は達成しましたが毎回上記のように書くのは冗長ですしプログラマブルにも扱いづらいので、図形毎にクラスにしてコンポーネント化してみます。
基本的にはPositionedで使うOffsetをフィールドで持ち、Positionedを返すbuildメソッドを持つクラスとしてカプセル化します。最初はクラスにせずに関数だけでも良いかな、と思ってたのですがWidget#buildに書いてるコードは毎回呼ばれてしまうのでクロージャだけで状態を保持するのは出来なさそうだったので、その他の利便性も考えてシンプルにクラスにしました。

まずは各図形の共通のインターフェスとなるDiagramを抽象クラスとして定義します。

abstract class Diagram {
  Diagram({
    required this.position,
    required this.size,
    required this.color,
  });
  Offset position;
  Size size;
  Color color;

  Widget build(State state);
}

それを継承する形でRectDiagramと従来通りReactPainterを作ります。

class RectDiagram extends Diagram {
  RectDiagram(
      {required super.position, required super.size, required super.color});

  
  Widget build(State state) {
    return Positioned(
      left: position.dx,
      top: position.dy,
      child: GestureDetector(
        onPanUpdate: (DragUpdateDetails details) {
          state.setState(() {
            position += details.delta;
          });
        },
        child: CustomPaint(
          size: size,
          painter: ReactPainter(
            color: color,
          ),
        ),
      ),
    );
  }
}

class ReactPainter extends CustomPainter {
  ReactPainter({
    required this.color,
  });
  final Offset offset = const Offset(0, 0);
  final Color color;

  
  void paint(Canvas canvas, Size size) {
    final paint = Paint();
    paint.color = color;
    canvas.drawRect(
        Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height), paint);
  }

  
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

Circleも同じくDiagramを継承したCircleDiagramとCirclePainterを下記のように作成します。

class CircleDiagram extends Diagram {
  CircleDiagram(
      {required super.position, required super.size, required super.color});

  
  Widget build(State state) {
    return Positioned(
      left: position.dx,
      top: position.dy,
      child: GestureDetector(
        onPanUpdate: (DragUpdateDetails details) {
          state.setState(() {
            position += details.delta;
          });
        },
        child: CustomPaint(
          size: size,
          painter: CirclePainter(
            color: color,
          ),
        ),
      ),
    );
  }
}

class CirclePainter extends CustomPainter {
  CirclePainter({
    required this.color,
  });
  final Color color;
  
  void paint(Canvas canvas, Size size) {
    final paint = Paint();
    final double radius = size.width / 2;

    paint.color = color;
    canvas.drawCircle(Offset(radius, radius), radius, paint);
  }

  
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

最後に以下のような配列diagramsをフィールドに作り、各図形のパラメータを与えて初期化します。Widget#buildメソッド内ではDiagram#buildを都度mapで呼んで描画をしています。

class _DrawPageState extends State<DrawPage> {
  var diagrams = [
    RectDiagram(
        color: Colors.yellow.shade200,
        size: const Size(200, 100),
        position: const Offset(10, 10)),
    RectDiagram(
        color: Colors.lightBlue.shade200,
        size: const Size(320, 320),
        position: const Offset(300, 10)),
    CircleDiagram(
        color: Colors.lightGreen.shade200,
        size: const Size(200, 200),
        position: const Offset(10, 150)),
  ];

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Stack(children: diagrams.map((x) => x.build(this)).toList()),
    );
  }
}

デモサイトとしてデプロイする

せっかくなのでデプロイするところまで。Flutter Webは静的ファイルなのでS3でもGitHub Pagesでも各種CDNでも好きな所にデプロイできます。今回はFirebase Hostingにデプロイしてみました。flutter build webを実行するとbuild/web/に静的コンテンツ一式が出来るので、これをデプロイしてやればOKです。

$ flutter build web
💪 Building with sound null safety 💪
Compiling lib\main.dart for the Web...                             670ms

$ firebase init hosting
...
? What do you want to use as your public directory? build/web/
? Configure as a single-page app (rewrite all urls to /index.html)? No
...
✔  Firebase initialization complete!

$ firebase deploy --only hosting
=== Deploying to ...
i  deploying hosting
i  hosting[sandbox-svc-dev-8rra]: beginning deploy...
i  hosting[sandbox-svc-dev-8rra]: found 21 files in build/web/
...
✔  Deploy complete!

以下から確認する事が出来ます。
https://sandbox-svc-dev-8rra.web.app/#/

まとめ

Visioっぽいツールを作るという壮大な目標には遠いですが、いったん第一歩を踏み出す事が出来ました。
ここから色々作りこみですね。今回初めてFlutterを使てみましたがVS Codeとの相性も良く、なかなか書き味の良い開発環境という印象を受けました。Web/Android/iOSそれぞれにクロスビルド出来るので、ネイティブ機能をガリガリ使うようなアプリ出なければ結構ありかもですね。Web版もWASMとかを駆使してネイティブアプリとのぶれをなるべく小さくしようとしていますし。
お道具箱に入れて置くと便利そうなので、引き続き触っていこうと思います。

それではHappy Hacking!

Discussion