Search code examples
flutterdartcanvasflutter-layout

Flutter keep background canvas drawing to skip repaint when only foreground changes


I am working on a project that has a CustomPaint that draws shapes as a background, and when you tap the screen, it draws a circle on that specific position. I am using GestureDetector to get the tap data and send it as an argument to _MyCustomPainter Something similar to the code below:

class MyApp extends StatefulWidget{
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {

  Offset _circlePosition = Offset(-1, -1);

  void setCirclePosition(Offset newPosition) {
    setState(() {
      this._circlePosition = newPosition;
    });
  }

  void clearCircle() {
    setState(() {
      this._circlePosition = Offset(-1, -1);
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          backgroundColor: Colors.green,
          title: Text('My app'),
        ),
        body: Container(
          child: GestureDetector(
            onTapDown: (detail) {
              setCirclePosition(detail.localPosition);
            },
            onHorizontalDragStart: (detail) {
              setCirclePosition(detail.localPosition);
            },
            onHorizontalDragUpdate: (detail) {
              setCirclePosition(detail.localPosition);
            },
            onVerticalDragStart: (detail) {
              setCirclePosition(detail.localPosition);
            },
            onVerticalDragUpdate: (detail) {
              setCirclePosition(detail.localPosition);
            },
            onTapUp: (detail) {
              clearCircle();
            },
            onVerticalDragEnd: (detail) {
              clearCircle();
            },
            onHorizontalDragEnd: (detail) {
              clearCircle();
            },
            child: LimitedBox(
              maxHeight: 400,
              maxWidth: 300,
              child: CustomPaint(
                size: Size.infinite,
                painter: new _MyCustomPainter(
                  circlePosition: circlePosition,
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _MyCustomPainter extends CustomPainter {
  _MyCustomPainter({
    this.circlePosition,
  });

  final Offset circlePosition;

  void _drawBackground(Canvas canvas) {
    // draw the background
  }

  @override
  void paint(Canvas canvas, Size size) {
    _drawBackground(canvas);
    if (circlePosition.dx != -1 && circlePosition.dy != -1) {
      // draws a circle on the position
      var circlePaint = Paint()..color = Colors.green;
      canvas.drawCircle(circlePosition, 5, circlePaint);
    }
  }

  @override
  bool shouldRepaint(_MyCustomPainter old) {
    return circlePosition.dx != old.circlePosition.dx ||
    circlePosition.dy != old.circlePosition.dy;
  }
}

So the thing here is, every time the user moves the finger on the screen with a long tap the background that doesn't change is being repainted over and over, and there is a lot of code involved in painting this specific background. There is the shouldRepaint method but it signals to repaint the whole widget. How could I make so that the background is only drawn once, and than on repaint I just use the background I created previously? Is using a PictureRecorder to generate an image and than using that image on future repaints the best way to do this? and also should my CustomPainter extend ChangeNotifier for better performance? The current approach works, but I am wondering how could I improve it.


Solution

  • So the approach I used to achieve this is having two CustomPainters, one for the background (let's call it BackgroundPainter) and one for the foregroud (like ForegroundPainter, and when the BackgroundPaiter.shouldRepaint method returns true, you draw it in a canvas and save it to an image, and use the image on the ForegroundPainter. I had to do this for this candlesticks chart widget here. A short version of the code would be something like the example bellow:

    class MyWidget extends StatefulWidget {
      MyWidget({
        this.foregroundParam,
        this.backgroundParam,
      });
    
      final String foregroundParam;
      final String backgroundParam;
    
      @override
      MyWidget createState() => _MyWidgetState();
    }
    
    class _MyWidgetState extends State<MyWidget> {
      Picture backgroundPicture;
      _BackgroundPainter backgroundPainter;
      Size parentSize;
      Size oldParentSize;
      
      @override
      Widget Build(BuildContext context) {
        // We need the context size, which is only available after
        // the build method finishes.
        WidgetsBinding.instance.addPostFrameCallback((_) {
          // this code only runs after build method is run
          if (parentSize != context.size) {
            setState(() {
              parentSize = context.size;
            });
          }
        });
        
        var newBackgroundPainter = _BackgroundPainter(
          backgroundParam: widget.backgroundParam,
        );
        
        // update backgroundPicture and backgroundPainter if parantSize was updated
        // (like after a screen rotation) or if backgroundPainter was updated
        // based on the backgroundParam
        if (parentSize != null &&
             (oldParentSize != null && oldParentSize != parentSize || 
             backgroundPainter == null || 
             newBackgroundPainter.shouldRepaint(backgroundPainter) ||
             backgroundPicture == null)
        ) {
          oldParentSize = parentSize;
          backgroundPainter = newBackgroundPainter;
          var recorder = PictureRecorder();
    
          var canvas = Canvas(recorder, Rect.fromLTWH(0, 0, parentSize.width, parentSize.height));
          backgroundPainter.paint(canvas, parentSize);
    
          setState(() {
            backgroundPicture = recorder.endRecording();
          });
        }
        // if there is no backgroundPicture, this must be the first run where
        // parentSize was not set yet, so return a loading screen instead, but
        // this is very quick and most likely will not even be noticed. Could return
        // an empty Container as well
        if (backgroundPicture == null) {
          return Container(
            width: double.infinity,
            height: double.infinity,
            child: Center(
              child: CircularProgressIndicator(),
            ),
          );
        }
    
        // if everything is set, return an instance of _ForegroundPainter passing
        // the backgroundPicture as parameter
        return CustomPaint(
          size: Size.infinite,
          painter: _ForegroundPainter(
            backgroundPicture: backgroundPicture,
            foregroundParam: widget.foregroundParam,
          ),
        );
      }
    }
    
    class _BackgroundPainter extends CustomPainter {
      _BackgroundPainter({
        this.backgroundParam,
      });
      final String backgroundParam;
    
      @override
      void paint(Canvas canvas, Size size) {
        // paint background here
      }
    
      @override
      bool shouldRepaint(_BackgroundPainter oldPainter) {
        return backgroundParam != oldPainter.backgroundParam;    
      }
    }
    
    class _ForegroundPainter extends CustomPainter {
      _ForegroundPainter({
        this.backgroundPicture,
        this.foregroundParam,
      });
      final Picture backgroundPicture;
      final String foregroundParam;
    
      @override
      void paint(Canvas canvas, Size size) {
        canvas.drawPicture(backgroundPicture);
    
        // paint foreground here
      }
    
      @override
      bool shouldRepaint(_ForegroundPainter oldPainter) {
        return foregroundParam != oldPainter.foregroundParam ||
        backgroundPicture != oldPainter.backgroundPicture;   
      }
    }