All Guides
    Advanced UI & Graphics
    UI
    Graphics
    CustomPainter
    Canvas

    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:


    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-axis extends to the right, and the Y-axis extends downward.
    • The width and height of the canvas are determined by the size parameter 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.round for curved tips, StrokeCap.square for 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:

    1. Coordinate Mapping: Center and radius values are calculated relative to the incoming container constraints.
    2. drawArc Start 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.
    3. StrokeCap.round: Rounds the edges of the progress indicator line.
    4. CustomPaint Widget: 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 size parameter passed to the paint() method to scale your layout coordinates dynamically.

    Best Practices

    • Implement Const Constructors: Add const modifiers to your painter class declarations to optimize memory usage.
    • Optimize Repaint Boundaries: Wrap your CustomPaint widget inside a RepaintBoundary widget 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:


    Summary

    • CustomPainter gives you access to a low-level vector canvas to draw custom paths and shapes.
    • The Canvas object provides drawing methods, and the Paint object configures styling properties.
    • Use shouldRepaint() to optimize performance by caching painted output.
    • Prevent layout issues by scaling drawing coordinates dynamically using the canvas size parameters.

    Next Steps

    1. Previous Guide: Named vs. Generated Routes
    2. Next Guide: Performance Optimization Tips
    3. 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