Flutter Custom Painters: Creating Custom Graphics and Animations
Have you ever looked at a Flutter app and wondered how developers create those beautiful custom shapes, charts, or animated graphics? While Flutter provides an excellent set of built-in widgets, sometimes you need something unique that goes beyond standard rectangles and circles. That's where Custom Painters come in!
Custom Painters are one of Flutter's most powerful features for creating custom graphics. They give you complete control over what gets drawn on the screen, allowing you to create everything from simple shapes to complex animations and data visualizations.
What Are Custom Painters?
At its core, a Custom Painter is a class that tells Flutter how to draw something on a canvas. Think of it like having a blank canvas and a set of brushes—you can draw whatever you imagine. The CustomPaint widget uses your custom painter to render graphics on the screen.
Custom Painters are particularly useful when you need to:
- Create custom shapes that aren't available as standard widgets
- Build charts and data visualizations
- Design unique loading animations
- Implement custom progress indicators
- Create artistic or decorative elements
The Basics: Understanding the CustomPainter Class
To create a custom painter, you need to extend the CustomPainter class and implement two key methods:
class MyCustomPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// Your drawing code goes here
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
// Return true if repainting is needed
return false;
}
}
The paint method is where all the magic happens. It receives a Canvas object (your drawing surface) and a Size object (the available space). The shouldRepaint method helps Flutter optimize performance by determining whether the painter needs to redraw when the widget rebuilds.
Your First Custom Painter: Drawing a Simple Shape
Let's start with something simple—drawing a custom rounded rectangle with a gradient fill. This will help you understand the fundamentals:
import 'package:flutter/material.dart';
class GradientBoxPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// Create a paint object with gradient
final paint = Paint()
..shader = LinearGradient(
colors: [Colors.blue, Colors.purple],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
).createShader(Rect.fromLTWH(0, 0, size.width, size.height))
..style = PaintingStyle.fill;
// Draw a rounded rectangle
final rect = RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, size.width, size.height),
const Radius.circular(20),
);
canvas.drawRRect(rect, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
To use this painter, wrap it in a CustomPaint widget:
CustomPaint(
painter: GradientBoxPainter(),
size: Size(200, 100),
child: Container(), // Optional child widget
)
Understanding the Canvas Coordinate System
Before diving into drawing, it's important to understand how Flutter's canvas coordinate system works. The origin (0, 0) is at the top-left corner, with X increasing to the right and Y increasing downward. This is different from traditional mathematical coordinates where Y increases upward.
Understanding the Canvas and Paint Objects
The Canvas is your drawing surface, and the Paint object defines how things are drawn. Let's break down the key properties of Paint:
color: The color to use for drawingstyle: EitherPaintingStyle.fill(filled shape) orPaintingStyle.stroke(outline only)strokeWidth: The thickness of lines when using stroke styleshader: For gradients and patterns
Here's an example that demonstrates different paint styles:
class StyleDemoPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// Filled circle
final fillPaint = Paint()
..color = Colors.blue
..style = PaintingStyle.fill;
canvas.drawCircle(
Offset(size.width * 0.25, size.height * 0.5),
30,
fillPaint,
);
// Outlined circle
final strokePaint = Paint()
..color = Colors.red
..style = PaintingStyle.stroke
..strokeWidth = 4;
canvas.drawCircle(
Offset(size.width * 0.75, size.height * 0.5),
30,
strokePaint,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
Creating Custom Shapes with Paths
For more complex shapes, you'll use the Path class. A path is like drawing with a pen—you move to points and draw lines or curves between them. Let's create a custom star shape:
class StarPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.amber
..style = PaintingStyle.fill;
final path = Path();
final centerX = size.width / 2;
final centerY = size.height / 2;
final radius = size.width / 3;
// Create a 5-pointed star
for (int i = 0; i < 5; i++) {
final angle = (i * 4 * 3.14159) / 5 - 3.14159 / 2;
final x = centerX + radius * cos(angle);
final y = centerY + radius * sin(angle);
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
Don't forget to import dart:math for the trigonometric functions:
import 'dart:math' as math;
// Then use math.cos() and math.sin()
Building a Custom Progress Indicator
One practical application of custom painters is creating unique progress indicators. Here's a circular progress indicator that shows progress as a percentage:
class CircularProgressPainter extends CustomPainter {
final double progress;
final Color color;
CircularProgressPainter({
required this.progress,
this.color = Colors.blue,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 10;
// Background circle
final backgroundPaint = Paint()
..color = Colors.grey[300]!
..style = PaintingStyle.stroke
..strokeWidth = 8;
canvas.drawCircle(center, radius, backgroundPaint);
// Progress arc
final progressPaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 8
..strokeCap = StrokeCap.round;
final sweepAngle = 2 * 3.14159 * progress;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-3.14159 / 2, // Start from top
sweepAngle,
false,
progressPaint,
);
}
@override
bool shouldRepaint(CircularProgressPainter oldDelegate) {
return oldDelegate.progress != progress || oldDelegate.color != color;
}
}
Notice how we've improved the shouldRepaint method to check if the progress or color has changed. This optimization ensures the painter only redraws when necessary.
Adding Animation to Custom Painters
Custom painters become even more powerful when combined with animations. Let's create an animated loading spinner:
class AnimatedSpinnerPainter extends CustomPainter {
final double rotation;
AnimatedSpinnerPainter({required this.rotation});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 10;
final paint = Paint()
..color = Colors.blue
..style = PaintingStyle.stroke
..strokeWidth = 6
..strokeCap = StrokeCap.round;
// Save canvas state
canvas.save();
// Rotate canvas
canvas.translate(center.dx, center.dy);
canvas.rotate(rotation);
canvas.translate(-center.dx, -center.dy);
// Draw arc
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
0,
3.14159 * 1.5, // 270 degrees
false,
paint,
);
// Restore canvas state
canvas.restore();
}
@override
bool shouldRepaint(AnimatedSpinnerPainter oldDelegate) {
return oldDelegate.rotation != rotation;
}
}
To animate this painter, use it with an AnimationController:
class AnimatedSpinner extends StatefulWidget {
@override
_AnimatedSpinnerState createState() => _AnimatedSpinnerState();
}
class _AnimatedSpinnerState extends State
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: Duration(seconds: 1),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return CustomPaint(
painter: AnimatedSpinnerPainter(
rotation: _controller.value * 2 * 3.14159,
),
size: Size(50, 50),
);
},
);
}
}
Performance Considerations
Custom painters can be performance-intensive, especially with complex drawings or animations. Here are some tips to keep your app running smoothly:
- Optimize shouldRepaint: Always check if the painter actually needs to redraw. Compare the old and new values of properties that affect the drawing.
- Use RepaintBoundary: Wrap your
CustomPaintwidget in aRepaintBoundaryto isolate repaints and prevent unnecessary rebuilds of parent widgets. - Avoid complex calculations in paint: Pre-calculate values outside the
paintmethod when possible. - Cache expensive operations: If you're drawing the same thing repeatedly, consider caching the result.
Here's an example of using RepaintBoundary:
RepaintBoundary(
child: CustomPaint(
painter: MyCustomPainter(),
size: Size(200, 200),
),
)
Real-World Example: A Simple Line Chart
Let's put everything together and create a simple line chart painter. This demonstrates how custom painters can be used for data visualization:
class LineChartPainter extends CustomPainter {
final List dataPoints;
final Color lineColor;
final Color fillColor;
LineChartPainter({
required this.dataPoints,
this.lineColor = Colors.blue,
this.fillColor = Colors.blue,
});
@override
void paint(Canvas canvas, Size size) {
if (dataPoints.isEmpty) return;
final maxValue = dataPoints.reduce((a, b) => a > b ? a : b);
final minValue = dataPoints.reduce((a, b) => a < b ? a : b);
final range = maxValue - minValue;
if (range == 0) return;
final path = Path();
final stepX = size.width / (dataPoints.length - 1);
// Create the line path
for (int i = 0; i < dataPoints.length; i++) {
final x = i * stepX;
final normalizedValue = (dataPoints[i] - minValue) / range;
final y = size.height - (normalizedValue * size.height);
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
// Draw filled area under the line
final fillPath = Path.fromPath(path)
..lineTo(size.width, size.height)
..lineTo(0, size.height)
..close();
final fillPaint = Paint()
..color = fillColor.withOpacity(0.3)
..style = PaintingStyle.fill;
canvas.drawPath(fillPath, fillPaint);
// Draw the line
final linePaint = Paint()
..color = lineColor
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
canvas.drawPath(path, linePaint);
// Draw data points
final pointPaint = Paint()
..color = lineColor
..style = PaintingStyle.fill;
for (int i = 0; i < dataPoints.length; i++) {
final x = i * stepX;
final normalizedValue = (dataPoints[i] - minValue) / range;
final y = size.height - (normalizedValue * size.height);
canvas.drawCircle(Offset(x, y), 4, pointPaint);
}
}
@override
bool shouldRepaint(LineChartPainter oldDelegate) {
return oldDelegate.dataPoints != dataPoints ||
oldDelegate.lineColor != lineColor ||
oldDelegate.fillColor != fillColor;
}
}
Common Patterns and Best Practices
As you work with custom painters, you'll notice some common patterns emerge:
- Center-based drawing: Many painters calculate the center point first and draw relative to it, making the design responsive to different sizes.
- Normalized coordinates: When working with data visualization, normalize your data to fit within the available canvas size.
- Path reuse: If you're drawing the same path multiple times with different paints, create the path once and reuse it.
- Canvas transformations: Use
canvas.save()andcanvas.restore()when applying transformations to avoid affecting subsequent drawings.
Conclusion
Custom Painters open up a world of possibilities in Flutter development. Whether you're creating unique UI elements, building data visualizations, or implementing custom animations, understanding how to use custom painters gives you the flexibility to bring your creative vision to life.
Remember to start simple and gradually build complexity. Practice with basic shapes before moving on to more advanced techniques. And always keep performance in mind—optimize your shouldRepaint methods and use RepaintBoundary when appropriate.
With custom painters in your toolkit, you're well-equipped to create Flutter apps that stand out with unique, beautiful graphics. Happy painting!