Custom Painter Guide
Published April 2026
Introduction
Flutter's standard widget catalog provides a rich collection of components for building modern user interfaces. However, you will eventually encounter design requirements that standard components cannot satisfy. Common examples include building dynamic data visualization charts, designing custom circular progress indicators, drawing freehand paths, or creating complex, animated layouts.
To implement custom designs, Flutter provides the CustomPaint and CustomPainter widgets. This layout system gives you access to a low-level drawing canvas, allowing you to paint vectors, lines, curves, and shapes directly onto the screen.
Using the canvas is similar to painting in a vector design program. You define colors, stroke thicknesses, path coordinates, and shapes. In this guide, you will learn the mathematics of canvas layout coordinates, configure custom brush options, and build a beautiful, animated circular progress indicator.
Prerequisites
Before implementing custom paint graphics, ensure you have:
- Read our guide on Understanding Widgets in Flutter.
- A basic grasp of geometric math principles (coordinate systems, radius, and angles in radians).
- Your local development environment configured and running (refer to Your First Flutter App for setup details).
Core Explanation: The Canvas, Paint, & Geometry
Custom painting in Flutter uses two primary elements: the Canvas (which provides drawing methods) and the Paint (which configures styling properties).
Canvas Coordinate Plane Circular Arc Math (Radians)
(0,0) ─────────────────► X-Axis (3*pi/2) = 270°
│ ▲
│ Center(X,Y) │
│ ┌─────────┐ pi ◄─┼─► 0 (or 2*pi) = 360°
│ │ (X,Y) │ │
▼ └─────────┘ ▼
Y-Axis (pi/2) = 90°
1. The Coordinate Plane
The canvas uses a 2D Cartesian coordinate system.
- The top-left corner of the container has coordinates
(0,0). - The
X-axisextends to the right, and theY-axisextends downward. - The width and height of the canvas are determined by the
sizeparameter passed to your paint function.
2. The Canvas (The Drawing Surface)
The Canvas object provides methods to draw shapes, including:
drawCircle(Offset center, double radius, Paint paint): Draws a circle.drawRect(Rect rect, Paint paint): Draws a rectangle.drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint): Draws a portion of a circle.drawPath(Path path, Paint paint): Draws complex, connected lines and bezier curves.
3. The Paint (The Styling Brush)
The Paint object configures your drawing properties:
color: The fill or stroke color.style: Controls whether to fill the shape (PaintingStyle.fill) or draw only its border outline (PaintingStyle.stroke).strokeWidth: The thickness of the border outline.strokeCap: The shape of the line ends (e.g.StrokeCap.roundfor curved tips,StrokeCap.squarefor flat tips).
4. Rebuild Optimization: shouldRepaint
The shouldRepaint() method returns a boolean indicating whether the painting logic needs to run again. If your painter depends on dynamic variables (like an animation percentage), return true when those variables change. If your layout is static, return false to optimize performance by caching the painted output.
Practical Example: A Circular Progress Indicator
Let's build a clean, compiler-ready custom circular progress indicator that draws a grey background track and animates a colored progress arc.
import 'dart:math' as math;
import 'package:flutter/material.dart';
// ==========================================
// CUSTOM PAINTER CLASS
// ==========================================
class ProgressCirclePainter extends CustomPainter {
final double progressPercent;
final Color trackColor;
final Color progressColor;
final double strokeThickness;
const ProgressCirclePainter({
required this.progressPercent,
required this.trackColor,
required this.progressColor,
required this.strokeThickness,
});
@override
void paint(Canvas canvas, Size size) {
// 1. Calculate center coordinates and radius based on container constraints
final centerOffset = Offset(size.width / 2, size.height / 2);
final radius = (math.min(size.width, size.height) - strokeThickness) / 2;
// 2. Configure background track styling brush
final trackPaint = Paint()
..color = trackColor
..style = PaintingStyle.stroke
..strokeWidth = strokeThickness;
// Draw background circle track
canvas.drawCircle(centerOffset, radius, trackPaint);
// 3. Configure progress arc styling brush
final progressPaint = Paint()
..color = progressColor
..style = PaintingStyle.stroke
..strokeWidth = strokeThickness
..strokeCap = StrokeCap.round; // Rounded line ends
// Define the bounding square box for the progress arc
final arcRect = Rect.fromCircle(center: centerOffset, radius: radius);
// Convert progress percentage to radians.
// In Flutter, 0 radians is at the 3 o'clock position.
// We start at the 12 o'clock position: -pi / 2.
const startAngle = -math.pi / 2;
final sweepAngle = 2 * math.pi * progressPercent;
// Draw the progress arc
canvas.drawArc(
arcRect,
startAngle,
sweepAngle,
false, // False since we don't want to close the shape to the center
progressPaint,
);
}
@override
bool shouldRepaint(covariant ProgressCirclePainter oldDelegate) {
// Optimize performance by rebuilding only when properties change
return oldDelegate.progressPercent != progressPercent ||
oldDelegate.trackColor != trackColor ||
oldDelegate.progressColor != progressColor ||
oldDelegate.strokeThickness != strokeThickness;
}
}
// ==========================================
// CONTAINER WIDGET WITH ANIMATION
// ==========================================
class AnimatedProgressCircle extends StatefulWidget {
final double targetProgress;
const AnimatedProgressCircle({
super.key,
required this.targetProgress,
});
@override
State<AnimatedProgressCircle> createState() => _AnimatedProgressCircleState();
}
class _AnimatedProgressCircleState extends State<AnimatedProgressCircle>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
);
_animation = Tween<double>(begin: 0.0, end: widget.targetProgress).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOutCubic),
);
_controller.forward();
}
@override
void didUpdateWidget(covariant AnimatedProgressCircle oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.targetProgress != widget.targetProgress) {
_animation = Tween<double>(
begin: _animation.value,
end: widget.targetProgress,
).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOutCubic),
);
_controller.forward(from: 0.0);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// CustomPaint links our painter class to the widget tree
AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return CustomPaint(
size: const Size(180, 180),
painter: ProgressCirclePainter(
progressPercent: _animation.value,
trackColor: Colors.grey[200]!,
progressColor: Theme.of(context).colorScheme.primary,
strokeThickness: 12.0,
),
child: SizedBox(
height: 180,
width: 180,
child: Center(
child: Text(
'${(_animation.value * 100).toInt()}%',
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
),
);
},
),
const SizedBox(height: 24.0),
const Text('CustomPainter Progress Indicator'),
],
),
);
}
}
Code Explanation:
- Coordinate Mapping: Center and radius values are calculated relative to the incoming container constraints.
drawArcStart Position: In coordinate geometry, 0 radians is at the 3 o'clock position. To start the progress indicator at the top of the circle, we offset the start angle by-pi / 2.StrokeCap.round: Rounds the edges of the progress indicator line.CustomPaintWidget: Integrates the painter into the layout hierarchy, providing a custom height and width.
Common Mistakes
1. Running Complex Calculations in the paint() Cycle
The paint() method runs on every frame of an animation (up to 120 times per second). Running heavy calculations inside it can cause frame drops (jank).
- The Fix: Compute static values (like path shapes) in your widget class and pass them to your painter as arguments.
2. Returning true from shouldRepaint Unconditionally
Unconditionally returning true from shouldRepaint forces Flutter to repaint the canvas on every rebuild, even if the drawing properties haven't changed.
- The Fix: Implement a proper equality check comparing the properties of the old painter with the new one.
3. Hardcoding Canvas Sizing Dimensions
Assuming fixed coordinates inside the painter will break the layout if the parent widget resizes.
- The Fix: Always use the dynamic
sizeparameter passed to thepaint()method to scale your layout coordinates dynamically.
Best Practices
- Implement Const Constructors: Add
constmodifiers to your painter class declarations to optimize memory usage. - Optimize Repaint Boundaries: Wrap your
CustomPaintwidget inside aRepaintBoundarywidget to isolate repaints and prevent them from triggering rebuilds in parent widgets. - Cache Complex Paths: Cache complex path calculations to reuse them across frames.
Related Concepts
Continue mastering advanced Flutter graphics by exploring these guides:
- Understanding Widgets in Flutter: Learn the difference between layouts and the rendering layer.
- Stack and Positioned Widgets: Learn how to layer widgets on top of each other.
- Performance Optimization Tips: Explore graphics caching and rebuild optimization strategies.
Summary
CustomPaintergives you access to a low-level vector canvas to draw custom paths and shapes.- The
Canvasobject provides drawing methods, and thePaintobject configures styling properties. - Use
shouldRepaint()to optimize performance by caching painted output. - Prevent layout issues by scaling drawing coordinates dynamically using the canvas
sizeparameters.
Next Steps
- Previous Guide: Named vs. Generated Routes
- Next Guide: Performance Optimization Tips
- Related Guide: Stack and Positioned Widgets
Ready to take the next step?
Start our free Flutter course and build real apps today.
Start Learning Now