FlutterのCustomPaintウィジェットでクリスマスツリーを描いて遊ぶ

本投稿は TECOTEC Advent Calendar 2021 の25日目の記事です。

こんにちは。次世代デジタル基盤開発事業部の六本木です。

テコテックといえばブロックチェーン。ブロックチェーンといえばビットコイン。ビットコインといえばマークルツリー!*1 ということで、マークルツリーのつくり方を共有します!

。。といきたいところですが、この季節はマークルツリーよりもクリスマスツリーですよね。 ということでクリスマスツリーのつくり方共有します!!

(マークルツリーのつくり方はわからないので、ちゃんと勉強してから いつか詳しく共有しますね)

また、わたくしFlutterの勉強中でして、最近FlutterのCustomPaintウィジェットを使うとお絵描きできることを知ったので、 今回クリスマスツリーはFlutterで作ってみたいと思います。

Flutterとは

FlutterとはGoogleが開発するモバイルアプリのクロスプラットフォーム開発可能なフレームワークで、 ウィジェット(Widget)と呼ばれるコンポーネントを組み合わせることにより柔軟にアプリ開発を行うことができます。 Flutterで用いるプログラミング言語Dartの構文がJavaやJavaScriptに似ているので、これらの言語の経験とオブジェクト指向の理解があれば、 簡単にアプリ開発を始めることができます。

flutter.dev

CustomPaintとは

Flutterのウィジェットの一つで、柔軟に絵をかくことができます。 既存のウィジェットには存在しない形のウィジェットを描きたいときなどに利用します。 三角形、矩形、曲線、円、なんでも書くことができます。

api.flutter.dev

では早速ですが始めましょう。

CustomPaintのひな型を用意する

Dart のコードスニペットツールである、DartPadにカスタムペイントのサンプルがあるので、そちらを使いましょう。

dartpad.dev

上記画像は2021/12 時点のものです。

左ペインにコード、右ペインにその結果が表示されています。 DartPadのSunflowerというサンプルが、CustomPaintの例となっています。

コードと結果を照らし合わせてじっくり眺めてもらえば、なんとなく、動きとその実装がわかってくるかと思います。

さて、今回作るのはクリスマスツリーですので、おもむろに「Sunflower」という単語を「XmasTree」に書き換えちゃいましょう。 私は一旦vimにコードをコピペし、「:%s/Sunflower/XmasTree/g」としました。

次に、骨格となるところ以外を削ります。

  • サンプルの、ひまわりの動きを実装している箇所 及び変数など
  • 今回は使用しないAppBar, Drawerなど

結果は下記です。

※削ってないけど一部コードの記載を省略している箇所があります。 このままでは動きません。あくまで説明用です。

void main() {
  runApp(XmasTree());
}

class XmasTreePainter extends CustomPainter { // ★1
  XmasTreePainter();

  @override
  void paint(Canvas canvas, Size size) { // ★2
  }
  @override
  bool shouldRepaint(XmasTreePainter oldDelegate) { // ★3
    return oldDelegate.seeds != seeds;
  }
}

class XmasTree extends StatefulWidget { // ★4
  @override
  State<StatefulWidget> createState() {
    return _XmasTreeState();
  }
}

class _XmasTreeState extends State<XmasTree> { // ★5
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
          child: Column(
            children: [
              Container(
                child: SizedBox(
                  child: CustomPaint( // ★6
                    painter: XmasTreePainter(seedCount),
                  ),),),
              Text("Showing $seedCount seeds"),
              ConstrainedBox( // ★7
                child: Slider.adaptive(
                  min: 20,
                  max: 2000,
                  value: seeds,
                  onChanged: (newValue) {
                    setState(() {
                      seeds = newValue;
                    });
                  },),),],),),),);
    }
}

それぞれ特筆する箇所を★でコメントしました。

★4, ★5 はFlutterに馴染みのない人はギョッとするかもしれませんが、StatefulWidgetを扱う際はよくやる書き方です。 理解していないうち は、細かいことを気にせず、そういうものだと思って書いてOKです。(かくいう私もその一人)

★6 でCustomPaint ウィジェットのコンストラクタ引数painterに、今回作成するペインターを指定しています。

★1にて、CustomPainterを継承したXmasTreePainterを定義しています。 抽象クラスCustomPainterは、以下2つをオーバーライドする必要があります。

  • ★2 paintメソッド:ここに絵を書いていきます。引数canvasに対して、draw〇〇(例えばcanvas.drawCircleで円)として絵を描きます。
  • ★3 shouldRepaintメソッド:名称とサンプルから、ウィジェットを再描画する条件を書くもの。という認識ですが、ちゃんとは理解していません。return Falseとしてもなぜかちゃんと動いているように見えます。。

★7は、サンプルでは種の数を調整するスライダーとなっていますが、クリスマスツリーの特徴を調整するスライダーに流用したいと思うので残しておきます。

単純図形でツリーを描こう

モミの木を描く
1. 葉の部分を描く

モミの木の尖った葉は、二等辺三角形を縦にずらしながら重ねていけば、表現することができます。 三角形は、いろいろと書き方があるかと思いますが、このように書きました。

  @override
  void paint(Canvas canvas, Size size) {
    var paint = Paint();
    // 葉っぱなので緑色
    paint.color = Colors.green; 
    // 描く図形の辿る経路
    var leaves = Path(); 
    // 三角形の頂点の座標
    Offset topPos = Offset(size.width / 2, size.height / 5);    
    // 三角形の1辺の長さ
    double sideLength = 100;
    
    leaves
       ..moveTo(topPos.dx, topPos.dy) // 開始点を指定
       ..lineTo(topPos.dx - sideLength * math.sin(math.pi / 6), // 直線の端のx座標(左下)
           topPos.dy + sideLength * math.cos(math.pi / 6))       // 直線の端のy座標(左下)
       ..lineTo(topPos.dx + sideLength / 2,                                // 直線の端のx座標(右下)
           topPos.dy + sideLength * math.cos(math.pi / 6));       // 直線の端のx座標(右下)
        
    leaves.close(); // 最後の点と、開始点を結ぶ
    canvas.drawPath(leaves, paint); //キャバスに描く

Pathのオブジェクト、leavesに対して、 「..」としているのはカスケード記法と呼ばれるもので、同一オブジェクトに対して複数の操作をするときに使える書き方です。 今回は、正三角形を作りました。

これと同じものを複数作成し、縦に少しずつずらしつつ重ねていけばいい感じになります。大きさも変えていくと、もっといい感じになります。

  @override
  void paint(Canvas canvas, Size size) {
    var paint = Paint();
    // 葉っぱなので緑色
    paint.color = Colors.green; 
    // 描く図形の辿る経路
    var leaves = Path(); 
    // クリスマスツリーの頂点の座標
    Offset topPos = Offset(size.width / 2, size.height / 5);    
    // クリスマスツリー頂点からの距離 dft: distance from top
    double dft = 0;
    // 三角形の1辺の長さ
    double sideLength = 0;
 // 三角形の数
 int treeSize = 20;

    for (i = 0; i < treeSize; i++) {
      dft = topPos.dy + (1 / 2) * math.pow(i + 1, 2).toDouble(); //頂点からの距離は二乗で伸びていくようにする
      sideLength = 10 * (i + 1); 

      leaves
        ..moveTo(topPos.dx, dft)
        ..lineTo(topPos.dx - sideLength * math.sin(math.pi / 6),
            dft + sideLength * math.cos(math.pi / 6))
        ..lineTo(topPos.dx + sideLength / 2,
            dft + sideLength * math.cos(math.pi / 6));
    }

    leaves.close();
    canvas.drawPath(leaves, paint);

結果がこちらです。

2. 木の幹と、スタンドカバーを描く

木の幹と、スタンドカバー(木の根元を隠すカバー?)は四角形で表現しましょう

    // もみの木のみき
    var trunkLeft = topPos.dx - i;
    var trunkTop = dft + sideLength * math.cos(math.pi / 6);
    var trunkWidth = 2 * i.toDouble();
    var trunkHeight = i.toDouble();
    paint.color = Colors.brown;
    var trunk = Rect.fromLTWH(trunkLeft, trunkTop, trunkWidth, trunkHeight);
    canvas.drawRect(trunk, paint);
   
    // スタンドカバー
    var standLeft = trunkLeft - i;
    var standTop = trunkTop + trunkHeight;
    var standWidth = 2 * trunkWidth;
    var standHeight = 2 * trunkHeight;
    var stand = Rect.fromLTWH(standLeft, standTop, standWidth, standHeight);
    canvas.drawRect(stand, paint);

四角形は「Rect.fromLTWH(左の座標, 上の座標, 幅, 高さ)」で定義することができます。

iは、葉を構成するループ内で使われた変数で、三角形の個数を表しています。

三角形の個数に応じて木の幹の太さを変えるイメージです。

オーナメントを飾ります

ペイントを重ねるときは基本後から書いたものが前面になります。 背面にしたいものから順番に書きましょう(描きましょう)。

1. ガーランド

ツリーに掛けるひも状のやつです。

    // ガーランド
    var garland = Paint()
      ..color = Colors.white                              // 色は白
      ..style = PaintingStyle.stroke                   // 線状
      ..strokeWidth = treeSize.toDouble() / 3; // 線の幅

    final garlandPath = Path();
    double startPointY = topPos.dy + (trunkTop - topPos.dy) * 1 / 3; // 開始地点のy座標(左上)
    double startPointX = topPos.dx - 12 * (treeSize / 3 + 1) / 2;         // 開始地点のx座標(左上)
    garlandPath.moveTo(startPointX, startPointY);                               // 開始地点を定義

    double endPointY = topPos.dy + (trunkTop - topPos.dy) * 2 / 3;  // 終了地点のy座標(右下)
    double endPointX = topPos.dx + 20 * (treeSize / 3 + 1) / 2;         // 終了地点のx座標(右下)

    garlandPath.quadraticBezierTo(startPointX, endPointY, endPointX, endPointY); // ベジェ曲線を描く

    canvas.drawPath(garlandPath, garland);

諸々数値がハードコードされていますが、これは絵を見て微調整して決定した値です。あまり気にしないでください。

Flutter(DartPad)にはホットリロード機能があり、保存したらすぐに結果に反映されるため、このような微調整が簡単にできます。

曲線は、quadraticBezierTo(中継地点のx座標, 中継地点のy座標, 終了地点x座標, 終了地点y座標) でベジェ曲線を描くことができます。

紐が垂れる曲線は、本当は重力加速度を加味したカテナリー曲線*2となるのですが、そこまでやると結構大変なので、これにて勘弁いただければと思います。

本当は複数ガーランドをかけたかったですが、座標の計算が大変だったため、寂しいですが一つとさせてください。

2. ボール

ガーランドが寂しい感じなので、せめてボールはカラフルにしましょう。 ランダムにボールを配置するようにしました。

    // ボール
    List<MaterialColor> colorList = [
      Colors.red,
      Colors.blue,
      Colors.lightGreen
    ];
    int ballNum = 20;
    for (int j = 0; j < ballNum; j++) {
      double randomPosY = math.Random().nextDouble() * (trunkTop - topPos.dy);
      double randomPosX =
          2 * (math.Random().nextDouble() - (1 / 2)) * randomPosY / 4;
      int randomColorIndex = math.Random().nextInt(colorList.length);
      paint.color = colorList[randomColorIndex];
      canvas.drawCircle(Offset(topPos.dx + randomPosX, topPos.dy + randomPosY),
          i * size.width / 1000, paint);
    }

今回は3色用意しました(赤、青、薄緑)

そして、20個のボールを配置するわけですが、その位置と色はランダムにします。

ファイルの先頭に「import 'dart:math' as math;」をすると、ランダムに数値を生成してくれるメソッドを使うことができるようになります。

「math.Random().nextDouble」で「0~1」までのランダムな実数、「math.Random().nextInt(数)」で「0~数」までのランダムな整数を生成することができます。

Y座標とX座標はツリーからはみ出さないようにだけ注意してください。宙に浮いてしまうので。

ボールは円で表現します。円は「 canvas.drawCircle(中心座標のオフセット, 半径, ペイントオブジェクト)」で描くことができます。

3. 星(五芒星)

これがあるとないとではクリスマスツリー感が100倍くらい違うと思いますので、必須です。

    paint.color = Colors.yellow;
    var star = Path();

    double radius = i.toDouble();
    for (int j = 0; j < 6; j++) {
      var angle = -math.pi / 10 + j * 4 * math.pi / 5;
      star.lineTo(radius * math.cos(angle) + topPos.dx,
          radius * math.sin(angle) + topPos.dy);
    }
    canvas.drawPath(star, paint);

一筆書きで星を描く要領でプログラムすればよいです。

線は5個引きますので5回ループします。

星の各頂点が円上に分布されると考えると、隣の頂点は、現時点での頂点の2π/5の角度にあります。

ただ、一筆書きする場合は頂点を一個スキップしますので 2×2π/5 = 4π /5 の角度にあります。

これを三角関数を使って直交座標に変換すればよいだけです。

これにて完成です! ただ、せっかくFlutterでやっていますのでもう少しだけ遊び心を入れましょう

木の高さとボールの数を調整できるようにする

Sunflowerのサンプルに調整スライダーがありましたので、それを流用させていただき、 木の高さとボールの数を調整できるようにしましょう。

class XmasTreePainter extends CustomPainter {
  // こちらのクラスにプロパティを保持
  final int treeSize;
  final int ballNum;

  // コンストラクタでプロパティに値を渡す
  XmasTreePainter(this.treeSize, this.ballNum);

  @override
  void paint(Canvas canvas, Size size) {
  略
  int treeSize = 20; // ←これを探して消すint ballNum = 20; // ←これを探して消す
  略  
  }

// スライダーの種別
enum barType {
  ballNum,
  treeSize,
}

class _XmasTreeState extends State<XmasTree> {
  // デフォルトの値
  int treeSize = 20; 
  int ballNum = 10;

  // スライダーの定義
  Widget _adjustmentSlider(barText, min, max, value, barType bar) {
    return Column(
      children: [
        Text("$barText $value"),
        ConstrainedBox(
          constraints: const BoxConstraints.tightFor(width: 200),
          child: Slider.adaptive(
            min: min,
            max: max,
            value: value.toDouble(),
            onChanged: (newValue) {
              setState(() {
                if (bar == barType.ballNum) {
                  ballNum = newValue.toInt();
                } else if (bar == barType.treeSize) {
                  treeSize = newValue.toInt();
                }
              });},),),],);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
          child: Column(
            children: [
              Container(
                child: SizedBox(
                  child: CustomPaint( 
                    painter: XmasTreePainter(treeSize, ballNum), // ★ここで値を渡す
                  ),),
              Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  _adjustmentSlider(
                      "木の大きさ", 3, 30, treeSize.toDouble(), barType.treeSize),
                  _adjustmentSlider(
                      "ボールの数", 1, 30, ballNum.toDouble(), barType.ballNum),
                ],}}
}

木の高さ「treeSize」とボールの数「ballNum」をスライダーで可変にできるようにし、それぞれの値をカスタムペイントに渡す形に修正しました。スライダーを調整すると動きがよくわかると思います。

まとめ

完成したプログラムはこちらです。

dartpad.dev

色々と改変して遊んでみてください。

  • オーナメントを追加する
  • ボールが光るアニメーションを追加する。
  • 他にも弄れるスライダーを追加する。(ガーランドの位置/数/太さを変える)

などなど

検証はしていませんが、Flutterですので、こちらで作成したプログラムはWeb, iOS, Androidで動かすことができると思われます。 ワンコードでいろいろなプラットフォームで動かすことができるのはやはり便利ですね。

では、メリークリスマス!

www.tecotec.co.jp