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?
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.