Saving picture of Canvas with PictureRecorder results in empty image

2,933

First off, thanks for the interesting question and the self-contained answer. That is refreshing to see and a lot easier to help out with!

There's a few issues with your code. The first is that you shouldn't be passing the canvas & points from Signature to SignatureState; that's an antipattern in flutter.

This code here works. I've done a few little things that were unnecessary to the answer to clean it up as well, sorry about that =D.

import 'dart:ui' as ui;

import 'package:flutter/material.dart';

///This class is use just to check if the Image returned by
///the PictureRecorder of the first Canvas is not empty.
class CanvasImageDraw extends CustomPainter {
  ui.Image image;

  CanvasImageDraw(this.image);

  @override
  void paint(ui.Canvas canvas, ui.Size size) {
    // simple aspect fit for the image
    var hr = size.height / image.height;
    var wr = size.width / image.width;

    double ratio;
    double translateX;
    double translateY;
    if (hr < wr) {
      ratio = hr;
      translateX = (size.width - (ratio * image.width)) / 2;
      translateY = 0.0;
    } else {
      ratio = wr;
      translateX = 0.0;
      translateY = (size.height - (ratio * image.height)) / 2;
    }

    canvas.translate(translateX, translateY);
    canvas.scale(ratio, ratio);
    canvas.drawImage(image, new Offset(0.0, 0.0), new Paint());
  }

  @override
  bool shouldRepaint(CanvasImageDraw oldDelegate) {
    return image != oldDelegate.image;
  }
}

///Class used to display the second canvas
class SecondView extends StatelessWidget {
  ui.Image image;

  var pictureRecorder = new ui.PictureRecorder();

  SecondView({this.image});

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
        appBar: new AppBar(
          title: new Text('Image Debug'),
        ),
        body: new Column(
          children: <Widget>[
            new Text('Top'),
            CustomPaint(
              painter: new CanvasImageDraw(image),
              size: new Size(200.0, 200.0),
            ),
            new Text('Bottom'),
          ],
        ));
  }
}

///This is the CustomPainter of the first Canvas which is used
///to draw to display the users draw/sign.
class SignaturePainter extends CustomPainter {
  final List<Offset> points;
  final int revision;

  SignaturePainter(this.points, [this.revision = 0]);

  void paint(canvas, Size size) {
    if (points.length < 2) return;

    Paint paint = new Paint()
      ..color = Colors.black
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 5.0;

    for (int i = 0; i < points.length - 1; i++) {
      if (points[i] != null && points[i + 1] != null)
        canvas.drawLine(points[i], points[i + 1], paint);
    }
  }

  // simplified this, but if you ever modify points instead of changing length you'll
  // have to revise.
  bool shouldRepaint(SignaturePainter other) => other.revision != revision;
}

///Classes used to get the user draw/sign, send it to the first canvas,
///then display it.
///'points' list of position returned by the GestureDetector
class Signature extends StatefulWidget {
  Signature({Key key}): super(key: key);

  @override
  State<StatefulWidget> createState()=> new SignatureState();
}

class SignatureState extends State<Signature> {
  List<Offset> _points = <Offset>[];
  int _revision = 0;

  ui.Image get rendered {
    var pictureRecorder = new ui.PictureRecorder();
    Canvas canvas = new Canvas(pictureRecorder);
    SignaturePainter painter = new SignaturePainter(_points);

    var size = context.size;
    // if you pass a smaller size here, it cuts off the lines
    painter.paint(canvas, size);
    // if you use a smaller size for toImage, it also cuts off the lines - so I've
    // done that in here as well, as this is the only place it's easy to get the width & height.
    return pictureRecorder.endRecording().toImage(size.width.floor(), size.height.floor());
  }

  void _addToPoints(Offset position) {
    _revision++;
    _points.add(position);
  }

  Widget build(BuildContext context) {
    return new Stack(
      children: [
        GestureDetector(
          onPanStart: (DragStartDetails details) {
            RenderBox referenceBox = context.findRenderObject();
            Offset localPosition = referenceBox.globalToLocal(details.globalPosition);
            setState(() {
              _addToPoints(localPosition);
            });
          },
          onPanUpdate: (DragUpdateDetails details) {
            RenderBox referenceBox = context.findRenderObject();
            Offset localPosition = referenceBox.globalToLocal(details.globalPosition);
            setState(() {
              _addToPoints(localPosition);
            });
          },
          onPanEnd: (DragEndDetails details) => setState(() => _addToPoints(null)),
        ),
        CustomPaint(painter: new SignaturePainter(_points, _revision)),
      ],
    );
  }
}

///The main class which display the first Canvas, Drawing/Signing area
///
///Floating action button used to stop the PictureRecorder's recording,
///then send the Picture to the next view/Canvas which should display it
class DemoApp extends StatelessWidget {
  GlobalKey<SignatureState> signatureKey = new GlobalKey();

  Widget build(BuildContext context) => new Scaffold(
        body: new Signature(key: signatureKey),
        floatingActionButton: new FloatingActionButton(
          child: new Icon(Icons.save),
          onPressed: () async {
            var image = signatureKey.currentState.rendered;
            Navigator.push(context, new MaterialPageRoute(builder: (context) => new SecondView(image: image)));
          },
        ),
      );
}

void main() => runApp(new MaterialApp(home: new DemoApp()));

The biggest issue you had is that you were holding your own canvas objects all over the place - that's not something you should ever do really. CustomPainter provides its own canvas. When you were painting, I don't think it was actually painting to the right place.

Also, you were recreating the list each time you added a point. I'm going to assume that's because your canvas wouldn't draw otherwise due to the other.points != points. I've changed it to take a revision int which is incremented each time something is added to the list. Even better would be to use your own list subclass that did this on its own, but that's a bit much for this example =D. You could also just use other.points.length != points.length if you're for sure never going to modify any of the elements in the list.

There were a few things to do considering the size of the image as well. I've gone the easiest route and made it so that the canvas is just the same size as its parent, so that the rendering is easier as it can just use that same size again (and therefore renders an image the size of the phone's screen). If you didn't want to do this but rather render your own size, it can be done. You'd have to pass in something to the SignaturePainter so it would know how to scale the points so they fit within the new size (you could adapt the aspect fit code in CanvasImageDraw for that if you so wished).

If you have any other questions about what I did, feel free to ask.

Share:
2,933
Antonio Machado
Author by

Antonio Machado

Updated on December 04, 2022

Comments

  • Antonio Machado
    Antonio Machado over 1 year

    First things first, the goal of this program is to allow user to sign officials document with a phone or tablet. The program has to save the image as a png.

    I use Flutter(and dart) and VS Code to develop this app.

    What works :

    -The user can draw on the canvas.
    

    What don't works:

    -the image can't be saved as a png
    

    What i found:

    -The **Picture** get by ending the **PictureRecoder** of the canvas is empty (i tried to display it but no success)
    -I tried to save it as a PNG using **PictureRecorder.EndRecording().toImage(100.0,100.0).toByteDate(EncodingFormat.png())** but the size is really small, and it can't be displayed.
    

    If some of you could give me some hint about where the problem could be, it would be really nice.

    FYI : Flutter is at the latest version on the dev channel

    Here's the full code :

    import 'dart:ui' as ui;
    import 'package:flutter/material.dart';
    
    ///This class is use just to check if the Image returned by
    ///the PictureRecorder of the first Canvas is not empty.
    ///FYI : The image is not displayed.
    class CanvasImageDraw extends CustomPainter {
    
        ui.Picture picture;
    
        CanvasImageDraw(this.picture);
    
        @override
        void paint(ui.Canvas canvas, ui.Size size) {
          canvas.drawPicture(picture);
        }
    
        @override
        bool shouldRepaint(CustomPainter oldDelegate) {
          return true;
        }
    }
    
    ///Class used to display the second canvas
    class SecondCanvasView extends StatelessWidget {
    
        ui.Picture picture;
    
        var canvas;
        var pictureRecorder= new ui.PictureRecorder();
    
        SecondCanvasView(this.picture) {
          canvas = new Canvas(pictureRecorder);
        }
    
        @override
        Widget build(BuildContext context) {
          return new Scaffold(
            appBar: new AppBar(
              title: new Text('Image Debug'),
            ),
            body: new Column(
              children: <Widget>[
                new Text('Top'),
                CustomPaint(
                  painter: new CanvasImageDraw(picture),
                ),
                new Text('Bottom'),
              ],
            ));
        }
    }
    
    ///This is the CustomPainter of the first Canvas which is used
    ///to draw to display the users draw/sign.
    class SignaturePainter extends CustomPainter {
      final List<Offset> points;
      Canvas canvas;
      ui.PictureRecorder pictureRecorder;
    
      SignaturePainter(this.points, this.canvas, this.pictureRecorder);
    
      void paint(canvas, Size size) {
        Paint paint = new Paint()
          ..color = Colors.black
          ..strokeCap = StrokeCap.round
          ..strokeWidth = 5.0;
        for (int i = 0; i < points.length - 1; i++) {
          if (points[i] != null && points[i + 1] != null)
            canvas.drawLine(points[i], points[i + 1], paint);
        }
      }
    
      bool shouldRepaint(SignaturePainter other) => other.points != points;
    }
    
    ///Classes used to get the user draw/sign, send it to the first canvas, 
    ///then display it.
    ///'points' list of position returned by the GestureDetector
    class Signature extends StatefulWidget {
      ui.PictureRecorder pictureRecorder = new ui.PictureRecorder();
      Canvas canvas;
      List<Offset> points = [];
    
      Signature() {
        canvas = new Canvas(pictureRecorder);
      }
    
      SignatureState createState() => new SignatureState(canvas, points);
    }
    
    class SignatureState extends State<Signature> {
      List<Offset> points = <Offset>[];
      Canvas canvas;
    
      SignatureState(this.canvas, this.points);
    
      Widget build(BuildContext context) {
        return new Stack(
          children: [
            GestureDetector(
              onPanUpdate: (DragUpdateDetails details) {
                RenderBox referenceBox = context.findRenderObject();
                Offset localPosition =
                referenceBox.globalToLocal(details.globalPosition);
    
                setState(() {
                  points = new List.from(points)..add(localPosition);
                });
              },
              onPanEnd: (DragEndDetails details) => points.add(null),
            ),
            new Text('data'),
            CustomPaint(
                painter:
                    new SignaturePainter(points, canvas, widget.pictureRecorder)),
            new Text('data')
          ],
        );
      }
    }
    
    
    ///The main class which display the first Canvas, Drawing/Signig area
    ///
    ///Floating action button used to stop the PictureRecorder's recording,
    ///then send the Picture to the next view/Canvas which should display it
    class DemoApp extends StatelessWidget {
      Signature sign = new Signature();
      Widget build(BuildContext context) => new Scaffold(
            body: sign,
            floatingActionButton: new FloatingActionButton(
              child: new Icon(Icons.save),
              onPressed: () async {
                ui.Picture picture = sign.pictureRecorder.endRecording();
                Navigator.push(context, new MaterialPageRoute(builder: (context) => new SecondCanvasView(picture)));
              },
            ),
      );
    }
    
    void main() => runApp(new MaterialApp(home: new DemoApp()));
    
  • Antonio Machado
    Antonio Machado about 6 years
    Thanks to you i see where the problem was. I don't have further questions, everything is clear now. Thanks a lot !