Norman
Norman

Reputation: 3157

Flutter: CustomPainter paint method gets called several times instead of only once

I have a simple app that draws via a CustomPainter a red or green circle on a canvas, depending on which button is pressed in the AppBar:

Red Circle
Green Circle

The class ColorCircle extends CustomPainter and is responsible for drawing the colored circle:

class ColorCircle extends CustomPainter {
  MaterialColor myColor;

  ColorCircle({@required this.myColor});
  
  @override
  void paint(Canvas canvas, Size size) {
    debugPrint('ColorCircle.paint, ${DateTime.now()}');
    final paint = Paint()..color = myColor;
    canvas.drawCircle(Offset(size.width / 2, size.height / 2), 100, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

The drawing of the different colors works fine, but when I click (only once!) or hover over one of the buttons, the paint method gets called several times:

Debugmessage


Further implementation details: I use a StatefulWidget for storing the actualColor. In the build method actualColor is passed to the ColorCircle constructor:

class _MyHomePageState extends State<MyHomePage> {
  MaterialColor actualColor = Colors.red;
    
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        actions: <Widget>[
          OutlinedButton(
            onPressed: () => setState(() => actualColor = Colors.red),
            child: Text('RedCircle'),
          ),
          OutlinedButton(
            onPressed: () => setState(() => actualColor = Colors.green),
            child: Text('GreenCircle'),
          ),
        ],
      ),
      body: Center(
        child: CustomPaint(
          size: Size(300, 300),
          painter: ColorCircle(myColor: actualColor),
        ),
      ),
    );
  }
}  

The complete source code with a running example can be found here: CustonPainter Demo


So why is paint called several times instead of only once? (And how could you implement it so that paint is called only once?).

Upvotes: 11

Views: 3092

Answers (2)

Mohammed Alfateh
Mohammed Alfateh

Reputation: 3524

All you need to do is to warp the CustomPaint with RepaintBoundary

   Center(
    child: RepaintBoundary(
      child: CustomPaint(
        size: Size(300, 300),
        painter: ColorCircle(myColor: actualColor),
      ),
    ),

By default CustomPainter is in the same layer as every other widget on the same screen so it's paint method will get called if any other widget on the same screen repaint. To fix this we can isolate the CustomPainter with RepaintBoundary so any repainting outside this RepaintBoundary wont effect it, or we can fix it by warping other widgets that would repaint with RepaintBoundary so they won't effect any other widgets (including the CustomPainter widget) when they get repaint, however it's better to just warp the CustomPainter with the RepaintBoundary instead of warping multiple widgets with RepaintBoundary since it's costly and sometimes have no effect.

You can get a better view and understanding of this by enabling Highlight repaints in the DevTools.

Upvotes: 22

Thierry
Thierry

Reputation: 8383

A poor solution might be to add a RepaintBoundary around the hover Widgets:

class _MyHomePageState extends State<MyHomePage> {
  MaterialColor actualColor = Colors.red;

  @override
  Widget build(BuildContext context) {
    print('Rebuilding with $actualColor');
    return Scaffold(
      appBar: AppBar(
        title: Text('CustomPainter Demo'),
        actions: <Widget>[
          RepaintBoundary(
            child: OutlinedButton(
                style: ButtonStyle(
                    foregroundColor: MaterialStateProperty.all(Colors.black)),
                onPressed: () {
                  setState(() => actualColor = Colors.red);
                },
                child: Text('RedCircle')),
          ),
          RepaintBoundary(
            child: OutlinedButton(
                style: ButtonStyle(
                    foregroundColor: MaterialStateProperty.all(Colors.black)),
                onPressed: () {
                  setState(() => actualColor = Colors.green);
                },
                child: Text('GreenCircle')),
          ),
        ],
      ),
      body: Center(
        child: CustomPaint(
          size: Size(300, 300),
          painter: ColorCircle(myColor: actualColor),
        ),
      ),
    );
  }
}

And then, to properly define the shouldRepaint method of the ColorCircle (currently returning false):

@override
bool shouldRepaint(CustomPainter oldDelegate) {
  return (oldDelegate as ColorCircle).myColor != myColor;
}

This seems to be a really poor solution. I would be interested to know of a better, more sustainable answer.

Full source code with RepaintBoundary workaround

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'CustomPainter Demo',
      home: MyHomePage(),
    );
  }
}

class ColorCirle extends CustomPainter {
  MaterialColor myColor;

  ColorCirle({@required this.myColor});
  @override
  void paint(Canvas canvas, Size size) {
    debugPrint('ColorCircle.paint, ${DateTime.now()}');
    final paint = Paint()..color = myColor;
    canvas.drawCircle(Offset(size.width / 2, size.height / 2), 100, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return (oldDelegate as ColorCirle).myColor != myColor;
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key key}) : super(key: key);

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  MaterialColor actualColor = Colors.red;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('CustomPainter Demo'),
        actions: <Widget>[
          RepaintBoundary(
            child: OutlinedButton(
                style: ButtonStyle(
                    foregroundColor: MaterialStateProperty.all(Colors.black)),
                onPressed: () {
                  setState(() => actualColor = Colors.red);
                },
                child: Text('RedCircle')),
          ),
          RepaintBoundary(
            child: OutlinedButton(
                style: ButtonStyle(
                    foregroundColor: MaterialStateProperty.all(Colors.black)),
                onPressed: () {
                  setState(() => actualColor = Colors.green);
                },
                child: Text('GreenCircle')),
          ),
        ],
      ),
      body: Center(
        child: CustomPaint(
          size: Size(300, 300),
          painter: ColorCirle(myColor: actualColor),
        ),
      ),
    );
  }
}

Upvotes: 2

Related Questions