Flutter Intermediate Lesson 76 of 135 5 min read
CustomPaint and Canvas in Flutter
Draw your own graphics in Flutter with CustomPaint and Canvas: lines, shapes, arcs, paths and text, and build an animated progress ring.
On this page
Sometimes no widget draws what you need: a chart, a gauge, a signature pad, an unusual shape. CustomPaint gives you a canvas and lets you draw directly.
The structure
import 'package:flutter/material.dart';
class CirclePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.teal
..style = PaintingStyle.fill;
final center = Offset(size.width / 2, size.height / 2);
canvas.drawCircle(center, size.shortestSide / 2, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
void main() {
runApp(
MaterialApp(
home: Scaffold(
body: Center(
child: CustomPaint(
size: const Size(200, 200),
painter: CirclePainter(),
),
),
),
),
);
}
| Piece | Role |
|---|---|
CustomPaint | The widget. Give it a size, or a child to size it |
CustomPainter | Your class. paint does the drawing |
Canvas | The surface, with methods such as drawLine and drawPath |
Size | How much room you have |
Paint | The brush: colour, stroke width, fill or outline |
shouldRepaint | Whether to redraw when the painter is replaced |
The origin (0, 0) is the top-left corner. X grows to the right and Y grows downwards.
Paint
final outline = Paint()
..color = Colors.indigo
..style = PaintingStyle.stroke // outline only
..strokeWidth = 6
..strokeCap = StrokeCap.round; // rounded line ends
final filled = Paint()..color = Colors.amber; // fill is the default
Drawing methods
canvas.drawLine(const Offset(0, 0), const Offset(100, 100), outline);
canvas.drawRect(const Rect.fromLTWH(10, 10, 80, 50), filled);
canvas.drawRRect(
RRect.fromRectAndRadius(const Rect.fromLTWH(10, 10, 80, 50), const Radius.circular(12)),
filled,
);
canvas.drawCircle(const Offset(50, 50), 30, outline);
canvas.drawOval(const Rect.fromLTWH(0, 0, 120, 60), outline);
canvas.drawArc(
const Rect.fromLTWH(0, 0, 100, 100),
0, // start angle, in radians (0 is 3 o'clock)
3.14, // sweep angle
false, // false: just the arc. true: a pie slice
outline,
);
Paths: any shape
A Path is a series of pen movements.
class TrianglePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final path = Path()
..moveTo(size.width / 2, 0) // top
..lineTo(size.width, size.height) // bottom right
..lineTo(0, size.height) // bottom left
..close();
canvas.drawPath(path, Paint()..color = Colors.deepOrange);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
quadraticBezierTo and cubicTo add curves, which is how wave headers and smooth chart lines are drawn.
A progress ring with a parameter
import 'dart:math' as math;
import 'package:flutter/material.dart';
class RingPainter extends CustomPainter {
RingPainter({required this.progress, required this.color, required this.track});
final double progress; // 0.0 to 1.0
final Color color;
final Color track;
@override
void paint(Canvas canvas, Size size) {
const stroke = 14.0;
final rect = Offset.zero & size;
final arcRect = rect.deflate(stroke / 2);
final base = Paint()
..style = PaintingStyle.stroke
..strokeWidth = stroke
..strokeCap = StrokeCap.round;
canvas.drawArc(arcRect, 0, 2 * math.pi, false, base..color = track);
canvas.drawArc(
arcRect,
-math.pi / 2, // start at 12 o'clock
2 * math.pi * progress,
false,
base..color = color,
);
}
@override
bool shouldRepaint(RingPainter oldDelegate) =>
oldDelegate.progress != progress || oldDelegate.color != color;
}
void main() => runApp(const MaterialApp(home: Scaffold(body: Center(child: RingDemo()))));
class RingDemo extends StatefulWidget {
const RingDemo({super.key});
@override
State<RingDemo> createState() => _RingDemoState();
}
class _RingDemoState extends State<RingDemo> {
double _target = 0.3;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return GestureDetector(
onTap: () => setState(() => _target = _target >= 1 ? 0.1 : _target + 0.3),
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _target.clamp(0, 1).toDouble()),
duration: const Duration(milliseconds: 600),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return CustomPaint(
size: const Size(180, 180),
painter: RingPainter(
progress: value,
color: colors.primary,
track: colors.surfaceContainerHighest,
),
child: SizedBox(
width: 180,
height: 180,
child: Center(
child: Text('${(value * 100).round()}%',
style: Theme.of(context).textTheme.headlineMedium),
),
),
);
},
),
);
}
}
Tap the ring to change the value. The painter is an ordinary class with final fields, and animating it means giving it a new progress on each frame.
shouldRepaint
Return true only when something that affects the drawing has changed. Returning true always wastes work. Returning false always means the picture never updates.
Repainting from an animation
Pass a Listenable to the painter’s super(repaint: ...). The canvas then repaints on every tick without rebuilding any widgets, which is the most efficient way to animate custom drawing.
class SpinnerPainter extends CustomPainter {
SpinnerPainter(this.animation) : super(repaint: animation);
final Animation<double> animation;
@override
void paint(Canvas canvas, Size size) {
// use animation.value here
}
@override
bool shouldRepaint(SpinnerPainter oldDelegate) => false;
}
Text on a canvas
final textPainter = TextPainter(
text: const TextSpan(text: '75%', style: TextStyle(color: Colors.black, fontSize: 24)),
textDirection: TextDirection.ltr,
)..layout();
textPainter.paint(
canvas,
Offset((size.width - textPainter.width) / 2, (size.height - textPainter.height) / 2),
);
It is usually simpler to put a Text widget in the child of CustomPaint, as the ring example does.
Clipping instead of painting
For a widget in an unusual shape, such as an image with a curved bottom edge, write a CustomClipper<Path> and use ClipPath. For simple cases, ClipRRect and ClipOval are enough.
Should you paint it yourself?
| Need | Better option |
|---|---|
| Rounded boxes, borders, shadows, gradients | BoxDecoration |
| Charts | The fl_chart or syncfusion_flutter_charts packages |
| Icons and illustrations | SVG with flutter_svg |
| Complex designed animation | rive or lottie |
| Something truly custom | CustomPaint |
Try it yourself
Draw a simple bar chart with CustomPaint from a list of five numbers: bars of equal width, heights in proportion to the values, with rounded tops. Then add a baseline and make the tallest bar a different colour.