Flutter Tutorial

Flutter Intermediate Lesson 76 of 135 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