Flutter Lesson 57 of 83 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.