2

I'm implementing a feature so the user can paint freely in a canvas in the screen. The expected behaviour is working very nicely: I can change the brush size and color and erase the drawing thanks to the call to canvas.saveLayer and canvas.restore.

The main issue happens when I've accumulated enough drawings to make the paint method very expensive to the application. I don't think adding RepaintBoundary will actually improve this, because so far the method is repainting the whole drawing as the user keeps drawing and its performance gets progressively worse, reaching 1 fps if you push so far.

Here is the code:

class DrawingCanvas extends StatefulWidget {
  const DrawingCanvas({
    super.key,
    required this.onPanUpdate,
    required this.onPanEnd,
    required this.initialDrawings,
    required this.selectedPainter,
    this.boundarySize = Size.infinite,
  });

  final List<DrawingDetails> initialDrawings;
  final void Function(Offset?) onPanUpdate;
  final void Function(Offset?) onPanEnd;
  final Paint selectedPainter;
  final Size boundarySize;

  @override
  State<DrawingCanvas> createState() => _DrawingCanvasState();
}

class _DrawingCanvasState extends State<DrawingCanvas> {
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onPanStart: (details) {
        setState(() {
          widget.onPanUpdate(details.localPosition);
        });
      },
      onPanUpdate: (details) {
        setState(() {
          widget.onPanUpdate(details.localPosition);
        });
      },
      onPanEnd: (details) {
        setState(() {
          widget.onPanEnd(null);
        });
      },
      child: CustomPaint(
        isComplex: true,
        painter: _DrawingPainter(
          initialDrawings: widget.initialDrawings,
          selectedPainter: widget.selectedPainter,
          boundarySize: widget.boundarySize,
        ),
        size: Size.infinite,
      ),
    );
  }
}

class _DrawingPainter extends CustomPainter {
  final List<DrawingDetails> initialDrawings;
  final Paint selectedPainter;
  final Logger logger = Logger('_DrawingPainter');
  final Size boundarySize;

  _DrawingPainter({
    required this.initialDrawings,
    required this.selectedPainter,
    required this.boundarySize,
  });

  @override
  bool shouldRepaint(_DrawingPainter oldDelegate) {
    return true;
  }

  void paint(Canvas canvas, Size size) {
    canvas.saveLayer(Rect.largest, Paint());
    for (DrawingDetails drawing in initialDrawings) {
      for (int i = 0; i < drawing.points.length - 1; i++) {
        if (drawing.points[i] != null && drawing.points[i + 1] != null) {
          canvas.drawLine(
            drawing.points[i]!,
            drawing.points[i + 1]!,
            drawing.painterOptions.painter,
          );
        }
      }
    }
    canvas.restore();
  }
}

I've also tried to avoid adding the same offset to the list if it is repeated, but it improves just a little bit, but doesn't keep the issue from happening.

Anyone has a suggestion on how I can solve this issue? Is there any improvement I am oblivious to?

18
  • 1
    you can checkout graphx,flame for this Commented Sep 14, 2024 at 13:44
  • 2
    instead of drawing everything using Canvas.drawLine try to convert after each (for example) 1000 points your canvas to Image - you do that with Canvas(PictureRecorder) ctor and then calling 1000 times Canvas.drawLine followed by Picure.toImage[Sync] - now your 1000 points can be drawn with simple Cnavas.drawImage Commented Sep 14, 2024 at 14:27
  • 1
    but first maybe try simple Canvas.drawPoints with PointMode.polygon? Commented Sep 14, 2024 at 14:36
  • Thanks, I'll try the simple one first and then caching if this one doesn't work. I'll probably do it on monday, so I'll mark any answer as correct then, don't worry xD @Md.YeasinSheikh do you mean graphx.flame from this package: github.com/roipeker/graph ? Commented Sep 14, 2024 at 14:55
  • yap I like those Commented Sep 14, 2024 at 16:57

1 Answer 1

1

Ok, so as suggested by @pskink I've added a logic in which every time the user takes his fingers out of the screen -- or, in other words, finish a line, I store it as an image and erase the previous list of points.

It looks like this:

class DrawingCanvas extends StatelessWidget {
  const DrawingCanvas({
    super.key,
    required this.onTouchStart,
    required this.onTouchUpdate,
    required this.onTouchEnd,
    required this.onCachingDrawing,
    required this.pointsAdded,
    required this.selectedPainter,
    required this.cachedDrawing,
    required this.shouldCacheDrawing,
    required this.pageOneImage,
    this.pageTwoImage,
    required this.child,
  });

  final Widget child;
  final List<DrawingDetails> pointsAdded;
  final void Function(Offset) onTouchStart;
  final void Function(Offset) onTouchUpdate;
  final void Function() onTouchEnd;
  final void Function(ui.Image) onCachingDrawing;
  final ui.Image? cachedDrawing;
  final bool shouldCacheDrawing;
  final Paint selectedPainter;
  final ui.Image? pageOneImage;
  final ui.Image? pageTwoImage;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onPanStart: (details) {
        onTouchStart(details.globalPosition);
      },
      onPanUpdate: (details) {
        onTouchUpdate(details.globalPosition);
      },
      onPanEnd: (_) {
        onTouchEnd();
      },
      child: ClipPath(
        child: CustomPaint(
          isComplex: true,
          willChange: true,
          foregroundPainter: _DrawingPainter(
            drawings: pointsAdded,
            selectedPainter: selectedPainter,
            onCachingDrawing: onCachingDrawing,
            cachedDrawing: cachedDrawing,
            shouldCacheDrawing: shouldCacheDrawing,
            pageOneImage: pageOneImage,
            pageTwoImage: pageTwoImage,
          ),
          child: child,
        ),
      ),
    );
  }
}

class _DrawingPainter extends CustomPainter {
  final List<DrawingDetails> drawings;
  final Paint selectedPainter;
  final Logger logger = Logger('_DrawingPainter');
  final Function(ui.Image) onCachingDrawing;
  final bool shouldCacheDrawing;
  final ui.Image? cachedDrawing;
  final ui.Image? pageOneImage;
  final ui.Image? pageTwoImage;

  _DrawingPainter({
    required this.drawings,
    required this.selectedPainter,
    required this.onCachingDrawing,
    required this.shouldCacheDrawing,
    required this.pageOneImage,
    this.pageTwoImage,
    this.cachedDrawing,
  });

  @override
  bool shouldRepaint(_DrawingPainter oldDelegate) {
    return (drawings.isNotEmpty &&
            (drawings.length == 1 && drawings[0].points.isNotEmpty)) &&
        oldDelegate.drawings != drawings;
  }

  @override
  void paint(Canvas canvas, Size size) {
    canvas.saveLayer(Rect.largest, Paint());

    final pictureRecorder = ui.PictureRecorder();
    final pictureCanvas = Canvas(pictureRecorder);

    if (cachedDrawing != null) {
      pictureCanvas.drawImage(cachedDrawing!, Offset.zero, Paint());
    }

    for (DrawingDetails drawing in drawings) {
      if (drawing.points.isEmpty) continue;
      if (isPointMode(drawing)) {
        pictureCanvas.drawPoints(
          ui.PointMode.points,
          [drawing.points[0]!],
          drawing.paint,
        );
      } else {
        for (int i = 0; i < drawing.points.length - 1; i++) {
          if (drawing.points[i] != null && drawing.points[i + 1] != null) {
            pictureCanvas.drawLine(
              drawing.points[i]!,
              drawing.points[i + 1]!,
              drawing.paint,
            );
          }
        }
      }
    }

    final picture = pictureRecorder.endRecording();

    canvas.drawPicture(picture);

    if (shouldCacheDrawing) {
      final ui.Image cachedImage = picture.toImageSync(
        size.width.toInt(),
        size.height.toInt(),
      );
      onCachingDrawing(cachedImage);
    }

    canvas.restore();
  }

  bool isPointMode(DrawingDetails drawing) =>
      drawing.points.length == 1 && drawing.points[0] != null;
}

The key is avoiding caching it at every frame using a flag, such as shouldCacheDrawing.

So, thanks you guys and sorry for the delay to post the result.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.