问题
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