【Flutter】Flutterでお絵かきアプリ(ペイント機能)の実装を紹介します

もくじ

こんにちは。

スマホアプリをメインに開発しているロッキーカナイです。

今回は、Flutterでお絵かき機能を実装してみましたので、その紹介をいたします。

ざっくり仕様

  1. 画面をなぞると線が描写されるようにする
  2. undo(元に戻す)機能の実装
  3. redo(やり直す)機能の実装
  4. clear(削除)機能の実装
  5. undoとredo、clearボタンはフローティングボタンに配置する

以上!

コーディング

import 'package:flutter/material.dart';
import 'package:flutter_test_app/PaintPage.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {

  @override
  Widget build(BuildContext context) {

    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: PaintPage(),
    );
  }
}

main.dartはペイントのページを呼び出すだけなので、以後変更はありません。

import 'package:flutter/material.dart';


/*
 * ペイントページ
 */
class PaintPage extends StatefulWidget {

  @override
  _PaintPageState createState() => _PaintPageState();
}


/*
 * ペイント ステート
 */
class _PaintPageState extends State<PaintPage> {

  @override
  Widget build(BuildContext context) {

    return Scaffold(

      /*
       * AppBar
       */
      appBar: AppBar(
        title: Text('ペイント'),
        centerTitle: true,
      ),

      /*
       * body
       */
      body: Container(
        color: Colors.blue,
      ),

      /*
       * floatingActionButton
       */
      floatingActionButton: Column(
        mainAxisAlignment: MainAxisAlignment.end,
        children: <Widget>[

          // undoボタン
          FloatingActionButton(
            heroTag: "undo",
            onPressed: () {
            },
            child: Text("undo"),
          ),

          SizedBox(
            height: 20.0,
          ),

          // redoボタン
          FloatingActionButton(
            heroTag: "redo",
            onPressed: () {
            },
            child: Text("redo"),
          ),

          SizedBox(
            height: 20.0,
          ),

          // クリアボタン
          FloatingActionButton(
            heroTag: "clear",
            onPressed: () {
            },
            child: Text("clear"),
          ),
        ],
      ),
    );
  }
}

PaintPage.dartではフローティングボタンの用意をしただけのクラスになります。

こちらをベースに変更を加えていきお絵かき機能の実装をしていきます。

Flutterでペイント機能で全体の制御をまとめると、

新規でPainterクラス、PaintControllerクラス、_CustomPainterクラス、PaintHistoryクラスをつくります。

Painterクラスでは以下の機能や制御を行います。

  • ペイントをコントロールするクラス(PaintController)を保持。
  • 指のジェスチャーの取得
  • 線を描写するクラス(_CustomPainter)を保持

PaintControllerクラスでは以下の機能や制御を行います。

  • ペイントの履歴(PaintHistory)を保持
  • 線の太さなどの設定データを保持(または変更できる)
  • undoやredo、clearの制御の受け取り口を持っていて実行をPaintHistoryへ指示する

PaintHistoryクラスでは以下の機能をもっています。

  • ジェクチャーを受け取り線のデータを管理
  • undoやredo、clear機能

_CustomPainterクラスは、CustomPainterを継承しており、描写のカスタマイズをするためのものです。

全体コード

PaintPage.dart

import 'package:flutter/material.dart';
import 'package:flutter_test_app/Painter.dart';


/*
 * ペイントページ
 */
class PaintPage extends StatefulWidget {

  @override
  _PaintPageState createState() => _PaintPageState();
}


/*
 * ペイント ステート
 */
class _PaintPageState extends State<PaintPage> {

  // コントローラ
  PaintController _controller = PaintController();

  @override
  Widget build(BuildContext context) {

    return Scaffold(

      /*
       * AppBar
       */
      appBar: AppBar(
        title: Text('ペイント'),
        centerTitle: true,
      ),

      /*
       * body
       */
      body: Container(
        child: Painter(
          paintController: _controller,
        ),
      ),

      /*
       * floatingActionButton
       */
      floatingActionButton: Column(
        mainAxisAlignment: MainAxisAlignment.end,
        children: <Widget>[

          // undoボタン
          FloatingActionButton(
            heroTag: "undo",
            onPressed: () {
              if (_controller.canUndo) _controller.undo();
            },
            child: Text("undo"),
          ),

          SizedBox(
            height: 20.0,
          ),

          // redoボタン
          FloatingActionButton(
            heroTag: "redo",
            onPressed: () {
              if (_controller.canRedo) _controller.redo();
            },
            child: Text("redo"),
          ),

          SizedBox(
            height: 20.0,
          ),

          // クリアボタン
          FloatingActionButton(
            heroTag: "clear",
            onPressed: () => _controller.clear(),
            child: Text("clear"),
          ),
        ],
      ),
    );
  }
}

Painter.dart

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test_app/PaintHistory.dart';


/*
 * ペイント
 */
class Painter extends StatefulWidget {

  // ペイントコントローラ
  final PaintController paintController;

  Painter({
    @required this.paintController
  }) : super(key: ValueKey<PaintController>(paintController)) {

    assert(this.paintController != null);
  }

  @override
  _PainterState createState() => _PainterState();
}


/*
 * ペイント ステート
 */
class _PainterState extends State<Painter> {

  @override
  Widget build(BuildContext context) {

    return Container(

      // イベント監視
      child: GestureDetector(

        // カスタムペイント
        child: CustomPaint(
          willChange: true,

          // ペイント部分
          painter: _CustomPainter(
            widget.paintController._paintHistory,
            repaint: widget.paintController,
          ),
        ),

        // イベントリスナー
        onPanStart: _onPaintStart,
        onPanUpdate: _onPaintUpdate,
        onPanEnd: _onPaintEnd,

      ),
      width: double.infinity,
      height: double.infinity,
    );
  }

  /*
   * 線ペイントの開始
   */
  void _onPaintStart(DragStartDetails start) {

    widget.paintController._paintHistory.addPaint(_getGlobalToLocalPosition(start.globalPosition));
    widget.paintController._notifyListeners();
  }

  /*
   * 線ペイント更新
   */
  void _onPaintUpdate(DragUpdateDetails update) {

    widget.paintController._paintHistory.updatePaint(_getGlobalToLocalPosition(update.globalPosition));
    widget.paintController._notifyListeners();
  }

  /*
   * 線ペイントの終了
   */
  void _onPaintEnd(DragEndDetails end) {

    widget.paintController._paintHistory.endPaint();
    widget.paintController._notifyListeners();
  }

  /*
   * ローカルのオフセットへ変換
   */
  Offset _getGlobalToLocalPosition(Offset global) {

    return (context.findRenderObject() as RenderBox).globalToLocal(global);
  }
}


/*
 * カスタムペイント
 */
class _CustomPainter extends CustomPainter {

  final PaintHistory _paintHistory;

  _CustomPainter(
      this._paintHistory,
      {
        Listenable repaint
      }) : super(repaint: repaint);

  @override
  void paint(Canvas canvas, Size size) {
    _paintHistory.draw(canvas, size);
  }

  @override
  bool shouldRepaint(_CustomPainter oldDelegate) => true;
}




/*
 * ペイントコントローラ
 */
class PaintController extends ChangeNotifier {

  // ペイント履歴
  PaintHistory _paintHistory = PaintHistory();
  // 線の色
  Color _drawColor = Color.fromARGB(255, 0, 0, 0);
  // 線幅
  double _thickness = 5.0;
  // 背景色
  Color _backgroundColor = Color.fromARGB(255, 255, 255, 255);

  /*
   * コンストラクタ
   */
  PaintController() : super() {

    // ペイント設定
    Paint paint = Paint();
    paint.color = _drawColor;
    paint.style = PaintingStyle.stroke;
    paint.strokeWidth = _thickness;
    _paintHistory.currentPaint = paint;
    _paintHistory.backgroundColor = _backgroundColor;
  }

  /*
   * undo実行
   */
  void undo() {

    _paintHistory.undo();
    notifyListeners();
  }

  /*
   * redo実行
   */
  void redo() {

    _paintHistory.redo();
    notifyListeners();
  }

  /*
   * undo可能か
   */
  bool get canUndo => _paintHistory.canUndo();

  /*
   * redo可能か
   */
  bool get canRedo => _paintHistory.canRedo();

  /*
   * リスナー実行
   */
  void _notifyListeners() {

    notifyListeners();
  }

  /*
   * クリア
   */
  void clear() {

    _paintHistory.clear();
    notifyListeners();
  }
}

PaintHistory.dart

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';


/*
 * ペイントデータ
 */
class _PaintData {

  _PaintData({
    this.path,
  }) : super();

  Path path; //  パス
}


/*
 * ペイントの履歴を管理するクラス
 */
class PaintHistory {

  // ペイントの履歴リスト
  List<MapEntry<_PaintData, Paint>> _paintList = List<MapEntry<_PaintData, Paint>>();
  // ペイントundoリスト
  List<MapEntry<_PaintData, Paint>> _undoneList = List<MapEntry<_PaintData, Paint>>();
  // 背景ペイント
  Paint _backgroundPaint = Paint();
  // ドラッグ中フラグ
  bool _inDrag = false;
  // カレントペイント
  Paint currentPaint;


  /*
   * undo可能か
   */
  bool canUndo() => _paintList.length > 0;

  /*
   * redo可能か
   */
  bool canRedo() => _undoneList.length > 0;

  /*
   * undo
   */
  void undo() {

    if (!_inDrag && canUndo()) {
      _undoneList.add(_paintList.removeLast());
    }
  }

  /*
   * redo
   */
  void redo() {

    if (!_inDrag && canRedo()) {
      _paintList.add(_undoneList.removeLast());
    }
  }

  /*
   * クリア
   */
  void clear() {

    if (!_inDrag) {
      _paintList.clear();
      _undoneList.clear();
    }
  }

  /*
   * 背景色セッター
   */
  set backgroundColor(color) => _backgroundPaint.color = color;

  /*
   * 線ペイント開始
   */
  void addPaint(Offset startPoint) {

    if (!_inDrag) {

      _inDrag = true;
      Path path = Path();
      path.moveTo(startPoint.dx, startPoint.dy);
      _PaintData data = _PaintData(path: path);
      _paintList.add(MapEntry<_PaintData, Paint>(data, currentPaint));
    }
  }

  /*
   * 線ペイント更新
   */
  void updatePaint(Offset nextPoint) {

    if (_inDrag) {

      _PaintData data = _paintList.last.key;
      Path path = data.path;
      path.lineTo(nextPoint.dx, nextPoint.dy);
    }
  }

  /*
   * 線ペイント終了
   */
  void endPaint() {

    _inDrag = false;
  }

  /*
   * 描写
   */
  void draw(Canvas canvas, Size size) {

    canvas.drawRect(
      Rect.fromLTWH(
        0.0,
        0.0,
        size.width,
        size.height,
      ),
      _backgroundPaint,
    );

    /*
     * 線描写
     */
    for (MapEntry<_PaintData, Paint> data in _paintList) {
      if (data.key.path != null) {

        canvas.drawPath(data.key.path, data.value);
      }
    }
  }
}

iOSシュミレータで確認

我ながら画伯っぷりを炸裂しましたな。

ちなみにundoやredo、clearも正常に動作しました。

PaintControllerを改変することで線の幅や色なども変更できます。

今回_PaintDataクラスには触れませんでしたが、ここに図形データや画像を入れられるようにすることで、線以外のお絵かきも可能です。それはまた機会があれば、紹介したいと思います。

エンドロール

エンドロールって書きたかっただけです。特になにもありません。

Flutter Webなるものがあるので、今後触る機会があったら記事にしたいと思います。

それでは。

関連する記事

もっと見る