Animating Custom Painters
Animating Custom Painters
A CustomPainter draws once by default. To make it animate, you need to drive its repaints from an Animation object. The key is passing the animation into your painter and declaring it as the repaint notifier — Flutter then calls paint() automatically on every animation tick without rebuilding the widget tree.
AnimationController produces a double from 0.0 to 1.0 over time. A CurvedAnimation or Tween transforms that double into any type or range you need. Your painter reads the current value and draws accordingly.The repaint Argument
CustomPainter has two optional constructor arguments for controlling repaints:
- repaint — a
Listenable(e.g. anAnimation) that triggers a repaint whenever it fires. This is the standard hook for animation. - shouldRepaint — called when the parent widget rebuilds and passes a new painter instance; return
trueif the new painter would draw differently.
By passing repaint: animation, you decouple the repaint cycle from the widget rebuild cycle. The CustomPaint widget subscribes to the Listenable and schedules a repaint on every tick — no setState required.
Wiring an Animation to a CustomPainter
class _ProgressRingPainter extends CustomPainter {
final Animation<double> animation;
// Pass animation as the repaint notifier
const _ProgressRingPainter({required this.animation})
: super(repaint: animation);
@override
void paint(Canvas canvas, Size size) {
final progress = animation.value; // 0.0 → 1.0
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 8;
// Background track
final trackPaint = Paint()
..color = const Color(0xFFE0E0E0)
..strokeWidth = 10
..style = PaintingStyle.stroke;
canvas.drawCircle(center, radius, trackPaint);
// Animated arc
final arcPaint = Paint()
..color = const Color(0xFF2196F3)
..strokeWidth = 10
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-3.14159 / 2, // start at top (−90°)
2 * 3.14159 * progress, // sweep angle driven by animation
false,
arcPaint,
);
}
@override
bool shouldRepaint(_ProgressRingPainter old) =>
old.animation != animation;
}
Hosting the Painter in a StatefulWidget
The StatefulWidget owns the AnimationController. You must mix in SingleTickerProviderStateMixin (or TickerProviderStateMixin for multiple controllers) so Flutter can drive the ticker. Dispose the controller in dispose() to avoid leaks.
Full Progress Ring Widget
class ProgressRing extends StatefulWidget {
final double targetProgress; // 0.0 to 1.0
const ProgressRing({super.key, required this.targetProgress});
@override
State<ProgressRing> createState() => _ProgressRingState();
}
class _ProgressRingState extends State<ProgressRing>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
);
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
_controller.animateTo(widget.targetProgress);
}
@override
void didUpdateWidget(ProgressRing old) {
super.didUpdateWidget(old);
if (old.targetProgress != widget.targetProgress) {
_controller.animateTo(widget.targetProgress);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CustomPaint(
size: const Size(120, 120),
painter: _ProgressRingPainter(animation: _animation),
);
}
}
Wave Animation: Combining Multiple Values
A single AnimationController set to repeat() drives a looping wave. By reading controller.value as a phase offset inside paint(), you shift a sine-wave path on every tick to produce the illusion of flowing water.
Wave Painter Using a Repeating Animation
class _WavePainter extends CustomPainter {
final Animation<double> animation;
final Color waveColor;
const _WavePainter({required this.animation, required this.waveColor})
: super(repaint: animation);
@override
void paint(Canvas canvas, Size size) {
final phase = animation.value * 2 * 3.14159; // 0 → 2π per loop
final amplitude = size.height * 0.08;
final midY = size.height * 0.55;
final path = Path()..moveTo(0, midY);
for (double x = 0; x <= size.width; x++) {
final y = midY +
amplitude * _sin((2 * 3.14159 * x / size.width) - phase);
path.lineTo(x, y);
}
path
..lineTo(size.width, size.height)
..lineTo(0, size.height)
..close();
canvas.drawPath(path, Paint()..color = waveColor);
}
// Simple sine approximation helper (use dart:math in real code)
double _sin(double radians) {
// In production: import 'dart:math'; return sin(radians);
return 0; // placeholder — replace with sin() from dart:math
}
@override
bool shouldRepaint(_WavePainter old) =>
old.waveColor != waveColor || old.animation != animation;
}
// Usage: controller.repeat() to loop the wave indefinitely
// _controller = AnimationController(vsync: this,
// duration: const Duration(seconds: 2))
// ..repeat();
dart:math in production code to use sin(), cos(), and pi. Replace magic numbers like 3.14159 with pi from that library for precision and readability.shouldRepaint vs repaint
These two methods serve different purposes and are often confused:
- repaint (Listenable) — called on every animation tick to schedule a repaint even when the widget has not rebuilt. This is what drives smooth animation.
- shouldRepaint(old) — called only when the parent rebuilds and creates a new painter. Return
trueif the new painter would produce a different picture (e.g. different colors or data). Returnfalseto skip an unnecessary repaint.
Paint or Path objects) inside a CustomPainter at the field level with late initialization per frame. Instead, allocate only what you need inside paint() or cache objects that do not change between frames. Dart's garbage collector will handle short-lived objects efficiently in most cases, but caching still helps in high-frequency scenarios.Performance Considerations
- Use
repaint: animationinstead ofsetState— it avoids rebuilding the widget tree on every frame. - Set
isComplex: trueonCustomPaintif the paint call is expensive — Flutter will rasterize and cache the layer. - Set
willChange: truewhen the painter changes frequently — Flutter avoids caching a layer it knows will be invalidated immediately. - Use
canvas.clipRectto restrict painting to the visible region and reduce overdraw.
Summary
Animating a CustomPainter requires three connected pieces: an AnimationController owned by a StatefulWidget, an Animation (optionally curved or tweened) passed into the painter, and the repaint: animation argument that subscribes the canvas to the animation's value stream. Reading animation.value inside paint() then gives you a continuously updated value to drive arcs, waves, fills, or any other custom drawing. Two canonical examples — a progress ring and a looping wave — demonstrate how the same pattern applies to both one-shot and repeating animations.