Animating CustomPainter with repaint whilst also retrieving Controller.value in flutter

被刻印的时光 ゝ 提交于 2020-08-10 19:19:29

问题


I've been looking around and it seems the most efficient and resource friendly way of animating a CustomPainter is to pass it an AnimationController as a repaint listenable, rather than using SetState(). I've tried this method, but unfortunately my CustomPaint object requires a variable that can be set both by the controller.value but also by the position of a user's pointer during a drag.

class CustomScroller extends StatefulWidget {
  final double width; //Should be width of user's screen
  CustomScroller(this.width);
  @override
  _CustomScrollerState createState() => _CustomScrollerState();
}

class _CustomScrollerState extends State<CustomScroller>
    with SingleTickerProviderStateMixin {
  ui.Image _img;
  ui.Picture picture;
  double height = 50;
  AnimationController _controller;
  Animation _animation;
  double dx = 0;
  Offset velocity = Offset(0, 0);
  double currentLocation = 0; //Variable that is required by NewScrollerPainter
  bool loading = true;
  List<PaintTick> upperTicks = [
    PaintTick(100, 4), //1cm,
    PaintTick(1000, 8), //10cm
    PaintTick(5000, 12), //50cm
    PaintTick(10000, 16) //1m
  ];
  List<PaintTick> lowerTicks = [
    PaintTick(254, 5), //1 inch
    PaintTick(3048, 10), //1 foot
  ];

  /// Calculates and runs a [SpringSimulation].
  void _runAnimation(Offset pixelsPerSecond, Size size) {
    _animation = _controller.drive(
      Tween(
        begin: currentLocation,
        end: currentLocation - velocity.dx / 5,
      ),
    );

    // Calculate the velocity relative to the unit interval, [0,1],
    // used by the animation controller.
    final unitsPerSecondX = pixelsPerSecond.dx / size.width;
    final unitsPerSecondY = pixelsPerSecond.dy / size.height;
    final unitsPerSecond = Offset(unitsPerSecondX, unitsPerSecondY);
    final unitVelocity = unitsPerSecond.distance;

    const spring = SpringDescription(
      mass: 30,
      stiffness: 1,
      damping: 0.5,
    );

    final simulation = SpringSimulation(spring, 0, 1, -unitVelocity);

    _controller.animateWith(simulation);
  }

  _loadImage() async {
    ui.PictureRecorder recorder = ui.PictureRecorder();
    Canvas canvas = Canvas(recorder);

    if (lowerTicks == null) {
      lowerTicks = upperTicks;
    }
    double lineHeight = 0;

    Color lineColor = Colors.blueGrey[300];

    Paint paint = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 1.2
      ..color = lineColor
      ..strokeCap = StrokeCap.round;

    for (int i = 1.toInt(); i < 10001.toInt(); i++) {
      if (upperTicks != null) {
        for (int j = 0; j < upperTicks.length; j++) {
          if ((i).remainder(upperTicks[j].getPosition()) == 0) {
            lineHeight = (upperTicks[j].getHeight());
          }
        }
        //Position to draw
        if (i.remainder(upperTicks[0].getPosition()) == 0) {
          //Draw a meters tick
          canvas.drawLine(Offset(widget.width * (i) / 10000, 0),
              Offset(widget.width * (i) / 10000, lineHeight), paint);
        }
      }
      if (lowerTicks != null) {
        for (int j = 0; j < lowerTicks.length; j++) {
          if ((i).remainder(lowerTicks[j].getPosition()) == 0) {
            lineHeight = (lowerTicks[j].getHeight());
          }
        }

        if ((i).remainder(lowerTicks[0].getPosition()) == 0) {
          //Draw a foot/inches tick
          canvas.drawLine(Offset((widget.width * (i) / 10000), height),
              Offset((widget.width * (i) / 10000), height - lineHeight), paint);
        }
      }
    }

    setState(() {
      picture = recorder.endRecording();
    });
  }

  @override
  void initState() {
    _controller = AnimationController(vsync: this);
    _loadImage();
    _controller.addListener(() {
      currentLocation =
          _animation.value; //Required variable modified by animation.
      setState(() {});
    });
    super.initState();
  }

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

  @override
  Widget build(BuildContext context) {
    Size size = MediaQuery.of(context).size;
    double width = MediaQuery.of(context).size.width;
    return GestureDetector(
      onHorizontalDragUpdate: (DragUpdateDetails dragUpdate) {
        dx = dragUpdate.delta.dx;
        currentLocation =
            currentLocation - dx; //Required variable modified by pointer drag.
        _controller.stop();
        setState(() {});
      },
      onHorizontalDragEnd: (DragEndDetails dragUpdate) {
        velocity = dragUpdate.velocity.pixelsPerSecond;
        _runAnimation(velocity, size);
      },
      child: Container(
        width: width,
        height: 50,
        child: Stack(
          children: <Widget>[
            Container(
              width: width,
              height: 50,
              child: CustomPaint(
                  painter:
                      NewScrollerPainter(currentLocation, picture, _controller),
                  size: Size.fromWidth(width)),
            ),
          ],
        ),
      ),
    );
  }
}

Removing the setState() in initState works, interestingly, after a hot reload, but not after a hot restart, though I'm not sure how that reflects on the nature of the problem. For the sake of completion, and in case you would like to test the code, here's my code for my NewScrollerPainter:

class NewScrollerPainter extends CustomPainter {
  AnimationController repaint;
  double offset;
  ui.Picture img;
  double minimumExtent = 0;
  double maximumExtent = 5;
  double currentLocation;
  NewScrollerPainter(this.currentLocation, this.img, this.repaint)
      : super(repaint: repaint);
  @override
  void paint(Canvas canvas, Size size) {
    int currentSet = (currentLocation / size.width).floor();
    double currentProgress = currentLocation / size.width - currentSet;
    canvas.translate(size.width / 2, 0);
    canvas.translate(-currentProgress * size.width, 0);
    if (currentSet >= minimumExtent && currentSet < maximumExtent) {
      canvas.drawPicture(img);
    }
    if (currentProgress > 0.5 &&
        currentSet < maximumExtent - 1 &&
        currentSet > minimumExtent - 2) {
      canvas.translate(size.width, 0);
      canvas.drawPicture(img);
    } else if (currentProgress < 0.5 &&
        currentSet > 0 &&
        currentSet < maximumExtent + 1) {
      canvas.translate(-size.width, 0);
      canvas.drawPicture(img);
    }
  }

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

Replacing the currentLocation variable in NewScrollerPainter with _controller.value just freezes the scroller, replacing it with _animation.value seems to instantly take the end of animation value with zero inbetween frames.


回答1:


Replacing the currentLocation variable in NewScrollerPainter with _controller.value just freezes the scroller, replacing it with _animation.value seems to instantly take the end of animation value with zero inbetween frames.

That's because you never really enter the listener when entering onHorizontalDragUpdate callback. If you see your callback

onHorizontalDragUpdate: (DragUpdateDetails dragUpdate) {
    dx = dragUpdate.delta.dx;
    currentLocation = currentLocation - dx; 
    //You're not changing its value to the controller's value, 
    //you're just depending of the dx and setState after that
    _controller.stop();
    setState(() {});
},

_controller.addListener(() { 
      //You never really triggers this when calling onHorizontalDragUpdate 
      // the controller it's never moving and calling the listener
      currentLocation =
          _animation.value; //Required variable modified by animation.
      setState(() {});
 });

The addListener in your init never runs (until the DragEnd becasue you run the animation in the _runAnimation method and that fires the listener). The currentLocation is a double that can go infinitely, but the controller.value has its uppderbound to 1 (it runs from 0 to 1), try changing the upperBound when creating the controller to infinite or a big number, and then use it in your customPaint.

Also if you want to build the ruler (I think that's what you're trying to paint) before the CustomPaint and just translate it when the gesture fires the animation, I would recommend using a FutureBuilder instead of running setState at the end of _loadImage. Think what could happen if the device is slow or the draw is too expensive and didn't end before creating NewScrollerPainter, you'll have null exception for a split of a second (or it could crash) because your paint is not ready yet. Create a completer in the initState and pass the future method to the complete (_loadImage) and in the FutureBuilder wait for the future of that completer

import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'dart:ui' as ui;
import 'dart:async';

class CustomScroller extends StatefulWidget {
  final double width; //Should be width of user's screen
  CustomScroller(this.width);
  @override
  _CustomScrollerState createState() => _CustomScrollerState();
}

class _CustomScrollerState extends State<CustomScroller>
    with SingleTickerProviderStateMixin {
  Completer _completer;
  double height = 50;
  AnimationController _controller;
  double dx = 0;
  Offset velocity = Offset(0, 0);
  bool loading = true; //you can use the completer to check if the paint is ready with _completer.isCompleted
  List<PaintTick> upperTicks = [
    PaintTick(100, 4), //1cm,
    PaintTick(1000, 8), //10cm
    PaintTick(5000, 12), //50cm
    PaintTick(10000, 16) //1m
  ];
  List<PaintTick> lowerTicks = [
    PaintTick(254, 5), //1 inch
    PaintTick(3048, 10), //1 foot
  ];

  /// Calculates and runs a [SpringSimulation].
  void _runAnimation(Offset pixelsPerSecond, Size size) {
    // Calculate the velocity relative to the unit interval, [0,1],
    // used by the animation controller.
    final unitsPerSecondX = pixelsPerSecond.dx / size.width;
    final unitsPerSecondY = pixelsPerSecond.dy / size.height;
    //print(unitsPerSecondY);
    final unitsPerSecond = Offset(unitsPerSecondX, unitsPerSecondY);
    final unitVelocity = unitsPerSecond.distance;

    const spring = SpringDescription(
      mass: 30,
      stiffness: 1,
      damping: 0.5,
    );

    final simulation = SpringSimulation(spring, _controller.value, 0, -unitVelocity);
    //the start position should be the place where the ruler right now (so it looks like a smooth animation), so _controller.value
    //the end position is where do you want it to end, if it's a spring I suppose you want to return to its origin, so 0

    _controller.animateWith(simulation);
  }

  Future<ui.Picture> _loadImage() async {
    ui.PictureRecorder recorder = ui.PictureRecorder();
    Canvas canvas = Canvas(recorder);

    if (lowerTicks == null) {
      lowerTicks = upperTicks;
    }
    double lineHeight = 0;

    Color lineColor = Colors.blueGrey[300];

    Paint paint = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 1.2
      ..color = lineColor
      ..strokeCap = StrokeCap.round;

    for (int i = 1; i <=10000; i++){
      if (upperTicks != null) {
        for (int j = 0; j < upperTicks.length; j++) {
          if ((i).remainder(upperTicks[j].getPosition()) == 0) {
            lineHeight = (upperTicks[j].getHeight());
          }
        }
        //Position to draw
        if (i.remainder(upperTicks[0].getPosition()) == 0) {
          //Draw a meters tick
          canvas.drawLine(Offset(widget.width * (i) / 10000, 0),
              Offset(widget.width * (i) / 10000, lineHeight), paint);
        }
      }
      if (lowerTicks != null) {
        for (int j = 0; j < lowerTicks.length; j++) {
          if ((i).remainder(lowerTicks[j].getPosition()) == 0) {
            lineHeight = (lowerTicks[j].getHeight());
          }
        }

        if ((i).remainder(lowerTicks[0].getPosition()) == 0) {
          //Draw a foot/inches tick
          canvas.drawLine(Offset((widget.width * (i) / 10000), height),
              Offset((widget.width * (i) / 10000), height - lineHeight), paint);
        }
      }
    }
    return recorder.endRecording();
  }

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(vsync: this, duration: Duration(seconds: 1), upperBound: double.maxFinite);
    //give an uppderBound of a big number or double.infinity
    //_controller = AnimationController.unbounded(vsync: this, duration: Duration(seconds: 1));
    //or you can use the constructor that gives you no bound like the comment above
    _completer = Completer<ui.Picture>()..complete(_loadImage()); //gives the future to complete to the completer
  }

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

  @override
  Widget build(BuildContext context) {
    Size size = MediaQuery.of(context).size;
    return GestureDetector(
      onHorizontalDragUpdate: (DragUpdateDetails dragUpdate) =>
        _controller.value -= dragUpdate.primaryDelta,
      onHorizontalDragEnd: (DragEndDetails dragUpdate) {
        velocity = dragUpdate.velocity.pixelsPerSecond;
        _runAnimation(velocity, size);
      },
      child: Stack(
        children: <Widget>[
          Container(
            width: widget.width,
            height: 50,
            child: FutureBuilder<ui.Picture>(
                future: _completer.future, //wait for the future to complete
                builder: (context, snapshot){
                  if(snapshot.hasData) //when the future completes you can use the Picture
                    return CustomPaint(
                        painter: NewScrollerPainter(snapshot.data, _controller),
                        size: Size.fromWidth(widget.width)
                    );
                  return const SizedBox(); //if it's not complete just return an empty SizedBox until it finished
                }
            ),
          ),
        ],
      ),
    );
  }
}

See how I deleted currentLocation from the code, there is no need of it anymore because you will use the controller.value to change the position

class NewScrollerPainter extends CustomPainter {
  Animation<double> repaint;
  double offset;
  ui.Picture img;
  double minimumExtent = 0;
  double maximumExtent = 5;
  NewScrollerPainter(this.img, this.repaint)
      : super(repaint: repaint);

  @override
  void paint(Canvas canvas, Size size) {
    int currentSet = (repaint.value / size.width).floor(); //now it will give you the real value
    double currentProgress = repaint.value / size.width - currentSet; //now it will give you the real value
    canvas.translate(size.width / 2, 0);
    canvas.translate(-currentProgress * size.width, 0);
    if (currentSet >= minimumExtent && currentSet < maximumExtent) {
      canvas.drawPicture(img);
    }
    if (currentProgress > 0.5 &&
        currentSet < maximumExtent - 1 &&
        currentSet > minimumExtent - 2) {
      canvas.translate(size.width, 0);
      canvas.drawPicture(img);
    } else if (currentProgress < 0.5 &&
        currentSet > 0 &&
        currentSet < maximumExtent + 1) {
      canvas.translate(-size.width, 0);
      canvas.drawPicture(img);
    }
  }

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


来源:https://stackoverflow.com/questions/62713997/animating-custompainter-with-repaint-whilst-also-retrieving-controller-value-in

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!