 
                   
                  
                【Flutter】Flutterでお絵かきアプリ(ペイント機能)の実装を紹介します
WRITER
 
                ロッキーカナイ
SwiftやObjective-CでiOS開発や、Flutterを用いたiOS/Androidアプリ開発、PHPでLaravelを使ったWebアプリ開発などをしてます。趣味は猫と戯れる事、キックボクシングにハマってます。ちなみに名前のロッキーカナイは以前よく昼飯を食べてた所。
こんにちは。
スマホアプリをメインに開発しているロッキーカナイです。
今回は、Flutterでお絵かき機能を実装してみましたので、その紹介をいたします。
ざっくり仕様
- 画面をなぞると線が描写されるようにする
- undo(元に戻す)機能の実装
- redo(やり直す)機能の実装
- clear(削除)機能の実装
- 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なるものがあるので、今後触る機会があったら記事にしたいと思います。
それでは。
 
         
         
         
         
         
         
         
         
         
         
         
         
         
         
         
         
         
         
       
         
     
     
    