Dart Tutorial

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(),
          ),
        ),
      ),
    ),
  );
}
PieceRole
CustomPaintThe widget. Give it a size, or a child to size it
CustomPainterYour class. paint does the drawing
CanvasThe surface, with methods such as drawLine and drawPath
SizeHow much room you have
PaintThe brush: colour, stroke width, fill or outline
shouldRepaintWhether 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? #

NeedBetter option
Rounded boxes, borders, shadows, gradientsBoxDecoration
ChartsThe fl_chart or syncfusion_flutter_charts packages
Icons and illustrationsSVG with flutter_svg
Complex designed animationrive or lottie
Something truly customCustomPaint

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.

Practise in the playground Updated by Santosh Adhikari