Javierd98
Javierd98

Reputation: 810

Flutter Improve CustomPainter animation performance

I needed a loading widget that draws the moving sine and cosine functions into a canvas. I coded it with no problem using a CustomPaint widget and a CustomPainter, but when I profile it, i Have discovered it runs on about 49fps, and not on 60fps. The UI thread is working good, taking about 6ms for each frame, but the Raster thread is taking longer. I have tried painting less points on the canvas (doing i=i+5 instead of i++ on the for loop), but the result is quite the same.

¿Can somebody suggest me an idea on how could I improve the performance?. The widget code is below, and so is the DevTools screenshot of what the Raster thread is doing in every frame, in case it can be useful.

import 'dart:math';

import 'package:flutter/material.dart';

class LoadingChart extends StatefulWidget{
  final Color color1;
  final Color color2;
  final double lineWidth;
  final bool line;
  final Size size;

  const LoadingChart({
    @required this.color1,
    @required this.color2,
    @required this.size,
    @required this.lineWidth,
    this.line = true,
    Key key
  }): super(key: key);

  @override
  State<StatefulWidget> createState() => _LoadingChartState();

}

class _LoadingChartState extends State<LoadingChart>
  with SingleTickerProviderStateMixin{
  AnimationController _controller;


  double randomHeight(Random random, double max){
    return random.nextDouble()*max;
  }

  @override
  void initState() {
    _controller = AnimationController(vsync: this, duration: Duration(seconds: 1));
    _controller.addListener(() {setState(() {});});
    _controller.repeat();

    super.initState();
  }

  @override
  void dispose(){
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: widget.size.height,
      width: widget.size.width,
      child: CustomPaint(
        painter: PathPainter(
          color1: widget.color1,
          color2: widget.color2,
          value: _controller.value,
          line: widget.line,
        ),
      )
    );
  }

}

class PathPainter extends CustomPainter {
  final Color color1;
  final Color color2;
  final double lineWidth;
  final bool line;

  final double value;

  PathPainter({
    @required this.value,
    this.color1=Colors.red,
    this.color2=Colors.green,
    this.line = true,
    this.lineWidth=4.0,
  }): super();

  @override
  void paint(Canvas canvas, Size size) {
    final height = size.height;
    final width = size.width;

    Paint paint1 = Paint()
      ..color = color1
      ..style = PaintingStyle.stroke
      ..strokeWidth = lineWidth;

    Paint paint2 = Paint()
      ..color = color2
      ..style = PaintingStyle.stroke
      ..strokeWidth = lineWidth;

    Path path1 = Path();
    Path path2 = Path();

    /* If line is true, draw sin and cos functions, otherwise, just some points */
    for (double i = 0; i < width; i=i+5){
      double f = i*2*pi/width + 2*pi*value;
      double g = i*2*pi/width - 2*pi*value;
      if (i == 0){
        path1.moveTo(0, height/2 + height/6*sin(f));
        path2.moveTo(0, height/2 + height/6*cos(g));
        continue;
      }

      path1.lineTo(i, height/2 + height/6*sin(f));
      path2.lineTo(i, height/2 + height/6*cos(g));
    }

    /* Draw both lines */
    canvas.drawPath(path1, paint1);
    canvas.drawPath(path2, paint2);
  }

  @override
  bool shouldRepaint(PathPainter oldDelegate) {
   return oldDelegate.value != value || oldDelegate.color1 != color1
     || oldDelegate.color2 != color2 || oldDelegate.line != line
     || oldDelegate.lineWidth != lineWidth;
  }
}

enter image description here enter image description here

PS: I'm running the app on profile mode so that shouldn't be the problem. Also I wanted to mention that it's the only widget being redrawn on the screen. Thanks a lot!!

Upvotes: 8

Views: 4978

Answers (1)

EdwynZN
EdwynZN

Reputation: 5601

CustomPainter can receive a listenable so maybe you can use the animation controller there to update it with every tick

class _LoadingChartState extends State<LoadingChart>
    with SingleTickerProviderStateMixin{
  AnimationController _controller;


  double randomHeight(Random random, double max){
    return random.nextDouble()*max;
  }

  @override
  void initState() {
    _controller = AnimationController(vsync: this, duration: Duration(seconds: 1));
    //_controller.addListener(() {setState(() {});}); no need to setState
    _controller.repeat();

    super.initState();
  }

  @override
  void dispose(){
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox(
        height: widget.size.height,
        width: widget.size.width,
        child: CustomPaint(
          willChange: true, //this can help (Whether the raster cache should be told that this painting is likely)
          painter: PathPainter(
            color1: widget.color1,
            color2: widget.color2,
            line: widget.line,
            listenable: _controller //pass the controller as it is (An animationController extends a Listenable)
          ),
        )
    );
  }
}

And in PathPainter you give to the constructor the listenable and pass it to the CustomPainter constructor that accepts a listenable called repaint

class PathPainter extends CustomPainter {
  final Animation listenable;
  final Color color1;
  final Color color2;
  final double lineWidth;
  final bool line;

  PathPainter({
    this.listenable,
    this.color1=Colors.red,
    this.color2=Colors.green,
    this.line = true,
    this.lineWidth=4.0,
  }): super(repaint: listenable); //don't forget calling the CustomPainter constructor with super

  @override
  void paint(Canvas canvas, Size size) {
    double value = listenable.value; // get its value here
    final height = size.height;
    final width = size.width;

    Paint paint1 = Paint()
      ..color = color1
      ..style = PaintingStyle.stroke
      ..strokeWidth = lineWidth;

    Paint paint2 = Paint()
      ..color = color2
      ..style = PaintingStyle.stroke
      ..strokeWidth = lineWidth;

    Path path1 = Path();
    Path path2 = Path();

    /* If line is true, draw sin and cos functions, otherwise, just some points */
    for (double i = 0; i < width; i=i+5){
      double f = i*2*pi/width + 2*pi*value;
      double g = i*2*pi/width - 2*pi*value;
      if (i == 0){
        path1.moveTo(0, height/2 + height/6*sin(f));
        path2.moveTo(0, height/2 + height/6*cos(g));
        continue;
      }

      path1.lineTo(i, height/2 + height/6*sin(f));
      path2.lineTo(i, height/2 + height/6*cos(g));
    }

    /* Draw both lines */
    canvas.drawPath(path1, paint1);
    canvas.drawPath(path2, paint2);
  }

  @override
  bool shouldRepaint(PathPainter oldDelegate) {
    //delete the oldDelegate.value, it doesn't exists anymore
    return oldDelegate.color1 != color1
        || oldDelegate.color2 != color2 || oldDelegate.line != line
        || oldDelegate.lineWidth != lineWidth;
  }
}

enter image description here

I'm in debug mode so I expect you get a better performance in profile mode

Upvotes: 8

Related Questions