相変わらず、育児と仕事の両立に苦戦している遠藤です。

さて、育児をしていると、子供の前でスマホを触ってしまうシーンが多々あります。
ソシャゲで遊んだり YouTube を眺めているわけではなく、育児の記録をつけたり、写真、動画を撮るために、半ば仕方なく触るのですが、子供(0歳11ヶ月)もスマホを触りたがります。
本当は、あまり見せたり触らせたくないのですが、興味を持ってしまうのであれば、いっそ子供でも安心して遊べるようにと、お絵描きアプリを作ってみました。

コードは弊社の Github にアップしています。
https://github.com/milldea/blog-flutter-paint-sample
clone すればそのまま動かせますので、興味がある方は是非動かしてみてください。

機能追加の PR もお待ちしています!

環境

Flutter のインストール自体は、公式をご確認ください。

インストール後の状態は以下の通りとなっています。

$ flutter doctor 
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.3.4, on macOS 12.6 21G115 darwin-arm, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 33.0.0-rc2)
[✓] Xcode - develop for iOS and macOS (Xcode 14.0.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2021.2)
[✓] VS Code (version 1.71.2)
[✓] Connected device (2 available)
[✓] HTTP Host Availability

• No issues found!

出来上がったもの

少し画質が荒いですが、以下のようなものが完成しました。

画面上部に色を変えるパレットが出ていて、あとは画面をなぞってお絵描きをします。
細かい機能として、 undo/redo や画像保存機能を設けました。

720p

機能と解説

コードの中身を一部、簡単に解説していきます。

お絵描き機能

該当コード

https://github.com/milldea/blog-flutter-paint-sample/blob/main/lib/main.dart#L426

これが、メイン機能になります。
CustomPainter を使って canvas に線を描画しています。
後で少し説明しますが、線だけでなく、直前まで描画済みの image を保存しておき、その上に線を描画するという方法を取っています。

// 実際に描画するキャンバス
class PaintCanvas extends CustomPainter{

  final List<LinePoints> lines;
  final List<Offset> nowPoints;
  final Color nowColor;
  final ui.Image? image;

  PaintCanvas(this.lines, this.nowPoints, this.nowColor, this.image);

  @override
  void paint(Canvas canvas, Size size) {
    Paint p = Paint()
      ..isAntiAlias = true
      ..color = Colors.redAccent
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 5.0;

    if (image != null) {
      canvas.drawImage(image!, const Offset(0, 0), p);
    }
    for (int i = 1; i < nowPoints.length; i++){
      Offset p1 = nowPoints[i - 1];
      Offset p2 = nowPoints[i];
      p.color = nowColor;
      canvas.drawLine(p1, p2, p);
    }
  }
  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}

ペンの色を変える

該当コード

https://github.com/milldea/blog-flutter-paint-sample/blob/main/lib/main.dart#L467

描画するペンの色を変えるボタンは、 ListView として画面上部に並べています。

// 色を変えるボタンClass
class ColorPallet extends StatelessWidget {
  final Color color;
  final Function changeColor;
  const ColorPallet({Key? key, required this.color, required this.changeColor, required this.isSelect}) : super(key: key);
  final bool isSelect;

  void onPressed(){
    changeColor(color);
  }

  @override
  Widget build(BuildContext context) {
    return RawMaterialButton(
        onPressed: onPressed,
        constraints: const BoxConstraints(minWidth: 85.0,minHeight: 80.0),
        child: Container(
          margin: const EdgeInsets.only(top: 5.0,bottom: 5.0),
          width: 80.0,
          height: 80.0,
          decoration: BoxDecoration(
              color: color,
              borderRadius: const BorderRadius.all(Radius.circular(40.0)),
              border: Border.all(color: Colors.white,width: isSelect?4.0:0.0)
          ),
        )) ;
  }
}

特に難しいところはないですが、変更できる色の指定を flutter 標準から持ってくることで、勝手に良い感じのグラデーションを表現できています。

List<MaterialAccentColor> colors = Colors.accents;

Floating Action Button をアニメーションさせて拡張する

該当コード

https://github.com/milldea/blog-flutter-paint-sample/blob/main/lib/main.dart#L371

子供が触るので、画面にできるだけメニューボタンを置きたくありませんでした。
ので、一つだけ FloatingActionButton を配置し、ボタンを押すとメニューが出てくるようにしました。
この実装は、公式がドキュメントを公開してくれていたので、そのまま採用しました。
https://docs.flutter.dev/cookbook/effects/expandable-fab

公式には色々書いてあって、結局どこを使えば良いのよ?っというのがパッと分からなかったので、実装では expandable_fab.dart を別ファイルにして、呼び出す形にしています。

https://github.com/milldea/blog-flutter-paint-sample/blob/main/lib/expandable_fab.dart

さすが公式だけあって、呼び出し方も簡単で、距離とボタンを指定するだけで使えるようになっています。

floatingActionButton: ExpandableFab(
        distance: 150.0,
        children: [
          ActionButton(
            onPressed: () {},
            icon: const Icon(Icons.delete),
            enabled: isWriteData(),
          ),
          ActionButton(
            onPressed: () {},
            icon: const Icon(Icons.save_alt),
            enabled: isWriteData(),
          ),
          ActionButton(
            onPressed: _undo,
            icon: const Icon(Icons.undo),
            enabled: canUndo(),
          ),
          ActionButton(
            onPressed: _redo,
            icon: const Icon(Icons.redo),
            enabled: canRedo(),
          ),
          ActionButton(
            onPressed: _showPallet,
            icon: Icon(showPallet ? Icons.color_lens_sharp : Icons.color_lens_outlined),
            enabled: true,
          ),
        ],
      ),

Undo & Redo

該当コード

https://github.com/milldea/blog-flutter-paint-sample/blob/main/lib/main.dart#L188-L219

進む & 戻る機能ですね。
0歳の子供は使わないだろ!っとツッコミが入りそうですが、もちろん、趣味で作りました。

描画では、一筆書き分の座標の配列と、その時の色をセットにして、Listとして持たせています。

// 一筆書き分の座標を持つClass
class LinePoints{
  final List<Offset> points;
  final Color lineColor;
  LinePoints(this.points, this.lineColor);
}

// state で更新する List
List<LinePoints> lines = <LinePoints>[];

Undo は、 lines から最後の要素を取り除けば実装できます。
removeLast(); という便利な関数があります。

ただ、それだけでは、 Redo が実装できません。
なので、 Undo したものを一時保存しておく UndoList を用意しておき、 Undo されたものを追加していきます。

Redo されたら、今度は UndoList から取り出して、 lines に戻していきます。

スクリーンショットを保存

該当コード

https://github.com/milldea/blog-flutter-paint-sample/blob/main/lib/main.dart#L244-L307

https://github.com/milldea/blog-flutter-paint-sample/blob/main/ios/Runner/Info.plist#L5-L8

https://github.com/milldea/blog-flutter-paint-sample/blob/main/android/app/src/main/kotlin/com/example/flutter_paint/MainActivity.kt

子供の書いた絵は、やっぱり記念に保存しておきたいですよね。
ということで、保存機能を実装しました。

Android はアプリ専用のフォルダを作成し、 MediaStore に追加します。
Android の実装では、こちらの方の実装を参考にしました。
https://www.evertop.pl/en/mediastore-in-flutter/

iOS は標準のフォルダアプリから見えるところに保存しています。

そして、以下は実際に、10ヶ月の娘が描いたイラスト(?)です。
まぁ、描いたというより、 触った というのが正しいですが、楽しそうでしたね。
とても可愛かったです。

2022-08-13_12-11-26

2022-08-15_05-01-14

描画処理の軽減

該当コード

https://github.com/milldea/blog-flutter-paint-sample/blob/main/lib/main.dart#L108-L133

最後のポイントとして、描画処理を軽減しています。
触った座標を配列で保持しておき、毎フレーム最初から描画を繰り返すことになるので、線を書けば書くほど、どんどん重たくなっていました。

そこで、今回は

  • 画面を触る
  • 座標を追加する
  • 最初から全て描画する
  • 画面を触る
  • 座標を追加する
  • 最初から全て描画する
  • ・・・

という流れで実装していたものを以下の流れに変更しました。

  • 画面を触る
  • 画像と追加分の座標を描画する
  • image として保持
  • 画面を触る
  • 画像と追加分の座標を描画する
  • image として保持
  • ・・・

一筆書き分の線が書き終わったタイミングで、前回作成した画像の上に、線を描画して画像として保存。
という処理を加えました。
実際の描画も、画像の上に現在一筆書き中の線のみを描画するだけにしました。

毎回画像生成が挟まるので、処理がやや煩雑になりましたが、処理落ちするようなことはなくなりました。

  // 線を Image にして保存
  Future<void> setOldImage() async{
    final recorder = ui.PictureRecorder();
    final canvas = Canvas(recorder);
    final p = Paint()
      ..isAntiAlias = true
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 5.0;
    if (image != null) {
      canvas.drawImage(image!, const Offset(0, 0), p);
    }
    for (int i = 1; i < nowPoints.length; i++){
      Offset p1 = nowPoints[i - 1];
      Offset p2 = nowPoints[i];
      p.color = nowColor;
      canvas.drawLine(p1, p2, p);
    }

    final picture = recorder.endRecording();
    int w = MediaQuery.of(context).size.width.toInt();
    int h = MediaQuery.of(context).size.height.toInt();
    ui.Image tmp = await picture.toImage(w, h);
    setState(() {
      image = tmp;
    });
  }

感想

というわけで、コツコツと 3日ほどかけて、アプリを作ってみたわけですが、子供は 3分くらい楽しそうに触って、去っていきました...。
もう遊んでくれません。
なんなら、開発期間含めて、私の方が楽しんでしまいました。

次は、子供の成長に合わせて、もっと楽しんでもらえるアプリを作ってみたいなと思います。