Flutter: Custom Paint & CustomPainter
When standard widgets aren't enough, the Canvas is your blank slate — any UI can be drawn stroke by stroke.
📋 Prerequisites: You should already be familiar with
- Lesson 15: Animation System
1. What You'll Learn
- CustomPaint + CustomPainter: paint() / shouldRepaint() lifecycle
- Canvas API: drawLine, drawRect, drawCircle, drawPath, drawParagraph
- Gesture integration: GestureDetector + CustomPainter for a signature pad
- Performance optimization: precise shouldRepaint checks + RepaintBoundary isolation
- ShopApp: sales data line chart + handwritten signature component
2. A Real Story of a Chart Requirement
(1) The Pain Point: Third-Party Chart Libraries Lack Customization
Bob's ShopApp needs a sales trend line chart with: gradient fill, tap-to-show-detail on data points, and animated drawing. Third-party chart libraries either don't support gradients, have inflexible tap callbacks, or uncontrollable animations. Bob got 80% of the way with fl_chart but couldn't modify the source code for the remaining 20%.
(2) The CustomPainter Solution
CustomPainter gives you full drawing control — every stroke, every pixel is up to you.
import 'package:flutter/material.dart';
// Custom class definition source:
// - data: List<double> type, representing sales data points
class SalesChartPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// Full control: draw exactly what you need
final linePaint = Paint()..color = Colors.blue..strokeWidth = 2..style = PaintingStyle.stroke;
final fillPaint = Paint()..shader = LinearGradient(colors: [Colors.blue.withOpacity(0.3), Colors.transparent])
.createShader(Rect.fromLTWH(0, 0, size.width, size.height));
// Draw path, fill, dots, labels...
}
@override
bool shouldRepaint(covariant SalesChartPainter old) => old.data != data;
}
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
(3) The Payoff: 100% Customization + Zero Third-Party Dependencies
Bob implemented the full requirement with CustomPainter in just 200 lines, with zero third-party dependencies and fully controllable animations.
3. CustomPainter System
graph LR
CP[CustomPaint] --> Painter[CustomPainter]
Painter --> Canvas[Canvas API]
Canvas --> DL[drawLine]
Canvas --> DR2[drawRect]
Canvas --> DC[drawCircle]
Canvas --> DP2[drawPath]
Painter --> |shouldRepaint| RP[Repaint Check]
CP --> RB[RepaintBoundary]
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
(1) CustomPainter Lifecycle
| Method | When Triggered | Purpose |
|---|---|---|
paint() |
First draw or when shouldRepaint returns true | Execute drawing commands |
shouldRepaint() |
Compare old vs new Painter | Decide whether to repaint |
repaint() |
Notify CustomPaint that repaint is needed | Works with AnimationController |
▶ Example
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
: Basic CustomPainter
import 'package:flutter/material.dart';
class PriceIndicatorPainter extends CustomPainter {
final double price;
final double maxPrice;
final Color color;
PriceIndicatorPainter({
required this.price,
required this.maxPrice,
this.color = Colors.green,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke;
final fillPaint = Paint()
..color = color.withOpacity(0.2)
..style = PaintingStyle.fill;
// Calculate bar height
final barHeight = (price / maxPrice) * size.height;
final rect = Rect.fromLTWH(0, size.height - barHeight, size.width, barHeight);
// Draw filled bar
canvas.drawRect(rect, fillPaint);
// Draw border
canvas.drawRect(rect, paint);
// Draw price text
final textSpan = TextSpan(text: '\$${price.toStringAsFixed(0)}',
style: TextStyle(color: color, fontSize: 12));
final tp = TextPainter(text: textSpan, textDirection: TextDirection.ltr);
tp.layout();
tp.paint(canvas, Offset(size.width / 2 - tp.width / 2, size.height - barHeight - 16));
}
@override
bool shouldRepaint(covariant PriceIndicatorPainter old) =>
old.price != price || old.maxPrice != maxPrice;
}
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
4. Canvas API Deep Dive
(1) Common Drawing Methods
| Method | Description | Parameters |
|---|---|---|
drawLine |
Draw a line | p1, p2, paint |
drawRect |
Draw a rectangle | rect, paint |
drawRRect |
Draw a rounded rectangle | rrect, paint |
drawCircle |
Draw a circle | center, radius, paint |
drawOval |
Draw an ellipse | rect, paint |
drawArc |
Draw an arc | rect, startAngle, sweepAngle, useCenter, paint |
drawPath |
Draw a path | path, paint |
drawShadow |
Draw a shadow | path, color, elevation, transparentOccluder |
drawParagraph |
Draw text | paragraph, offset |
▶ Example
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
: Drawing a gradient line chart
import 'package:flutter/material.dart';
class LineChartPainter extends CustomPainter {
final List<double> dataPoints;
final Color lineColor;
final double maxValue;
LineChartPainter({
required this.dataPoints,
this.lineColor = Colors.blue,
required this.maxValue,
});
@override
void paint(Canvas canvas, Size size) {
if (dataPoints.isEmpty) return;
final linePaint = Paint()
..color = lineColor
..strokeWidth = 2.5
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final dotPaint = Paint()
..color = lineColor
..style = PaintingStyle.fill;
final gridPaint = Paint()
..color = Colors.grey[300]!
..strokeWidth = 0.5;
// Draw grid lines
for (int i = 0; i <= 4; i++) {
final y = size.height * i / 4;
canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint);
}
// Calculate points
final points = <Offset>[];
for (int i = 0; i < dataPoints.length; i++) {
final x = (i / (dataPoints.length - 1)) * size.width;
final y = size.height - (dataPoints[i] / maxValue) * size.height;
points.add(Offset(x, y));
}
// Draw gradient fill
final fillPath = Path()
..moveTo(points.first.dx, size.height)
..lineTo(points.first.dx, points.first.dy);
for (int i = 1; i < points.length; i++) {
fillPath.lineTo(points[i].dx, points[i].dy);
}
fillPath.lineTo(points.last.dx, size.height);
fillPath.close();
final fillPaint = Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [lineColor.withOpacity(0.3), lineColor.withOpacity(0.05)],
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
canvas.drawPath(fillPath, fillPaint);
// Draw line
final linePath = Path()..moveTo(points.first.dx, points.first.dy);
for (int i = 1; i < points.length; i++) {
linePath.lineTo(points[i].dx, points[i].dy);
}
canvas.drawPath(linePath, linePaint);
// Draw dots
for (final point in points) {
canvas.drawCircle(point, 4, dotPaint);
}
}
@override
bool shouldRepaint(covariant LineChartPainter old) =>
old.dataPoints != dataPoints || old.maxValue != maxValue;
}
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
5. Gesture Integration: Signature Pad
▶ Example
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
: SignaturePad component
import 'package:flutter/material.dart';
import 'dart:ui' as ui;
class SignaturePad extends StatefulWidget {
final ValueChanged<ui.Image>? onSignatureComplete;
const SignaturePad({super.key, this.onSignatureComplete});
@override
State<SignaturePad> createState() => _SignaturePadState();
}
class _SignaturePadState extends State<SignaturePad> {
final List<Offset?> _points = [];
final _painterKey = GlobalKey();
void _onPointerDown(PointerDownEvent event) {
final box = _painterKey.currentContext!.findRenderObject() as RenderBox;
setState(() => _points.add(event.localPosition));
}
void _onPointerMove(PointerMoveEvent event) {
final box = _painterKey.currentContext!.findRenderObject() as RenderBox;
setState(() => _points.add(event.localPosition));
}
void _onPointerUp(PointerUpEvent event) {
setState(() => _points.add(null)); // null = line break
}
void _clear() => setState(() => _points.clear());
@override
Widget build(BuildContext context) {
return Column(children: [
Container(
height: 200,
decoration: BoxDecoration(border: Border.all(color: Colors.grey), borderRadius: BorderRadius.circular(8)),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Listener(
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
child: CustomPaint(key: _painterKey, size: Size.infinite, painter: SignaturePainter(_points)),
),
),
),
const SizedBox(height: 8),
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
TextButton(onPressed: _clear, child: const Text('Clear')),
const SizedBox(width: 8),
FilledButton(onPressed: () {
// Capture signature as image
widget.onSignatureComplete?.call(null); // Simplified
}, child: const Text('Confirm')),
]),
]);
}
}
class SignaturePainter extends CustomPainter {
final List<Offset?> points;
SignaturePainter(this.points);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.black
..strokeWidth = 2.0
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke;
for (int i = 0; i < points.length - 1; i++) {
if (points[i] != null && points[i + 1] != null) {
canvas.drawLine(points[i]!, points[i + 1]!, paint);
}
}
}
@override
bool shouldRepaint(covariant SignaturePainter old) => old.points != points;
}
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
6. Performance Optimization
▶ Example
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
: RepaintBoundary repaint isolation
import 'package:flutter/material.dart';
// Custom class definition source:
// - ProductHeader/ProductDescription: custom Widgets
// - LineChartPainter: see Section 4 in this lesson
// - salesData: List<double> type
// Without RepaintBoundary: entire page repaints on chart update
Column(children: [
ProductHeader(product: product),
RepaintBoundary( // Only chart repaints
child: CustomPaint(painter: LineChartPainter(data: salesData)),
),
ProductDescription(product: product), // NOT repainted
])
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
| Optimization Technique | Effect |
|---|---|
shouldRepaint precise check |
Avoid unnecessary repaints |
RepaintBoundary |
Isolate repaint scope |
PictureRecorder cache |
Cache complex graphics as Image |
| Avoid creating objects in paint | Reduce GC pressure |
7. Complete Example: ShopApp Sales Chart Component
import 'package:flutter/material.dart';
// Custom class definition source:
// - LineChartPainter: see Section 4 in this lesson
// - SalesData: see definition below
class SalesChart extends StatelessWidget {
final List<SalesData> data;
final double width;
final double height;
const SalesChart({super.key, required this.data, this.width = 350, this.height = 200});
@override
Widget build(BuildContext context) {
final maxRevenue = data.map((d) => d.revenue).reduce((a, b) => a > b ? a : b);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Monthly Revenue (USD)', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
SizedBox(width: width, height: height,
child: RepaintBoundary(child: CustomPaint(
painter: LineChartPainter(
dataPoints: data.map((d) => d.revenue).toList(),
maxValue: maxRevenue * 1.1,
lineColor: Theme.of(context).colorScheme.primary,
),
size: Size(width, height),
)),
),
const SizedBox(height: 8),
// Legend
Wrap(spacing: 16, children: data.take(6).map((d) => Text(
'${d.month}: \$${(d.revenue / 1000).toStringAsFixed(0)}K',
style: const TextStyle(fontSize: 11),
)).toList()),
]),
),
);
}
}
class SalesData {
final String month;
final double revenue;
const SalesData({required this.month, required this.revenue});
}
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
❓ FAQ
drawRRect(RRect.fromRectAndRadius(rect, radius), paint).ui.PictureRecorder() to record Canvas drawing, then endRecording().toImage() to convert to ui.Image.📖 Summary
- CustomPainter + paint() gives full drawing control; shouldRepaint controls repainting
- Canvas API is rich: lines/rectangles/circles/paths/text/shadows/gradients
- Listener/Pointer events enable gesture-based drawing (signature pad)
- RepaintBoundary isolates repaint scope; shouldRepaint precise checks avoid waste
- Line charts are the most typical real-world application of CustomPainter
📝 Exercises
- Basic (Difficulty ⭐): Use CustomPainter to draw a circular progress bar (0-100%) with a color gradient from green to red.
- Intermediate (Difficulty ⭐⭐): Implement a sales line chart with grid lines, gradient fill, data point markers, and a tooltip showing details on data point tap.
- Challenge (Difficulty ⭐⭐⭐): Implement a complete handwritten signature component: touch drawing + clear + save as image + stroke width varying with speed.