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

1. What You'll Learn


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.

DART
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;
}
TEXT
> 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

100%
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]
TEXT
> 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

TEXT
> 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

DART
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;
}
TEXT
> 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

TEXT
> 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

DART
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;
}
TEXT
> 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

TEXT
> 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

DART
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;
}
TEXT
> 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

TEXT
> 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

DART
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
])
TEXT
> 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

DART
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});
}
TEXT
> 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

Q When does CustomPainter need to repaint?
A When the Painter's properties change. You must correctly implement shouldRepaint: compare old and new properties, return true if changed.
Q How is CustomPaint's size determined?
A If it has a child, CustomPaint follows the child's size. Without a child, use the size property or constrain it with SizedBox in the parent.
Q How to draw a rounded rectangle on Canvas?
A Use drawRRect(RRect.fromRectAndRadius(rect, radius), paint).
Q How to save the signature pad as an image?
A Use ui.PictureRecorder() to record Canvas drawing, then endRecording().toImage() to convert to ui.Image.
Q What's wrong with always returning true in shouldRepaint?
A Every build triggers a repaint, even when data hasn't changed. Fine for simple components, but causes jank in complex charts.
Q Why use drawParagraph instead of Text in paint?
A Canvas can't directly draw Widgets. Text must be laid out via TextPainter/Paragraph and then painted with paint(canvas, offset).

📖 Summary


📝 Exercises

  1. Basic (Difficulty ⭐): Use CustomPainter to draw a circular progress bar (0-100%) with a color gradient from green to red.
  2. Intermediate (Difficulty ⭐⭐): Implement a sales line chart with grid lines, gradient fill, data point markers, and a tooltip showing details on data point tap.
  3. Challenge (Difficulty ⭐⭐⭐): Implement a complete handwritten signature component: touch drawing + clear + save as image + stroke width varying with speed.

← Previous Lesson | Next Lesson →

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏