← Back to Articles

Flutter Custom Painters: Creating Custom Graphics and Animations

Flutter Custom Painters: Creating Custom Graphics and Animations

Flutter Custom Painters: Creating Custom Graphics and Animations

Have you ever looked at a Flutter app and wondered how developers create those beautiful custom shapes, charts, or animated graphics? While Flutter provides an excellent set of built-in widgets, sometimes you need something unique that goes beyond standard rectangles and circles. That's where Custom Painters come in!

Custom Painters are one of Flutter's most powerful features for creating custom graphics. They give you complete control over what gets drawn on the screen, allowing you to create everything from simple shapes to complex animations and data visualizations.

What Are Custom Painters?

At its core, a Custom Painter is a class that tells Flutter how to draw something on a canvas. Think of it like having a blank canvas and a set of brushes—you can draw whatever you imagine. The CustomPaint widget uses your custom painter to render graphics on the screen.

Custom Painters are particularly useful when you need to:

  • Create custom shapes that aren't available as standard widgets
  • Build charts and data visualizations
  • Design unique loading animations
  • Implement custom progress indicators
  • Create artistic or decorative elements
CustomPaint Widget Architecture CustomPaint Widget CustomPainter paint() method

The Basics: Understanding the CustomPainter Class

To create a custom painter, you need to extend the CustomPainter class and implement two key methods:


class MyCustomPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // Your drawing code goes here
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    // Return true if repainting is needed
    return false;
  }
}

The paint method is where all the magic happens. It receives a Canvas object (your drawing surface) and a Size object (the available space). The shouldRepaint method helps Flutter optimize performance by determining whether the painter needs to redraw when the widget rebuilds.

Your First Custom Painter: Drawing a Simple Shape

Let's start with something simple—drawing a custom rounded rectangle with a gradient fill. This will help you understand the fundamentals:


import 'package:flutter/material.dart';

class GradientBoxPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // Create a paint object with gradient
    final paint = Paint()
      ..shader = LinearGradient(
        colors: [Colors.blue, Colors.purple],
        begin: Alignment.topLeft,
        end: Alignment.bottomRight,
      ).createShader(Rect.fromLTWH(0, 0, size.width, size.height))
      ..style = PaintingStyle.fill;

    // Draw a rounded rectangle
    final rect = RRect.fromRectAndRadius(
      Rect.fromLTWH(0, 0, size.width, size.height),
      const Radius.circular(20),
    );
    
    canvas.drawRRect(rect, paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

To use this painter, wrap it in a CustomPaint widget:


CustomPaint(
  painter: GradientBoxPainter(),
  size: Size(200, 100),
  child: Container(), // Optional child widget
)

Understanding the Canvas Coordinate System

Before diving into drawing, it's important to understand how Flutter's canvas coordinate system works. The origin (0, 0) is at the top-left corner, with X increasing to the right and Y increasing downward. This is different from traditional mathematical coordinates where Y increases upward.

Canvas Coordinate System (0, 0) X increases → Y increases ↓ Center point

Understanding the Canvas and Paint Objects

The Canvas is your drawing surface, and the Paint object defines how things are drawn. Let's break down the key properties of Paint:

  • color: The color to use for drawing
  • style: Either PaintingStyle.fill (filled shape) or PaintingStyle.stroke (outline only)
  • strokeWidth: The thickness of lines when using stroke style
  • shader: For gradients and patterns
Fill vs Stroke Styles Fill Style Stroke Style

Here's an example that demonstrates different paint styles:


class StyleDemoPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // Filled circle
    final fillPaint = Paint()
      ..color = Colors.blue
      ..style = PaintingStyle.fill;
    
    canvas.drawCircle(
      Offset(size.width * 0.25, size.height * 0.5),
      30,
      fillPaint,
    );

    // Outlined circle
    final strokePaint = Paint()
      ..color = Colors.red
      ..style = PaintingStyle.stroke
      ..strokeWidth = 4;
    
    canvas.drawCircle(
      Offset(size.width * 0.75, size.height * 0.5),
      30,
      strokePaint,
    );
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

Creating Custom Shapes with Paths

For more complex shapes, you'll use the Path class. A path is like drawing with a pen—you move to points and draw lines or curves between them. Let's create a custom star shape:

How Paths Work moveTo() lineTo() lineTo() close() connects back to start

class StarPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.amber
      ..style = PaintingStyle.fill;

    final path = Path();
    final centerX = size.width / 2;
    final centerY = size.height / 2;
    final radius = size.width / 3;
    
    // Create a 5-pointed star
    for (int i = 0; i < 5; i++) {
      final angle = (i * 4 * 3.14159) / 5 - 3.14159 / 2;
      final x = centerX + radius * cos(angle);
      final y = centerY + radius * sin(angle);
      
      if (i == 0) {
        path.moveTo(x, y);
      } else {
        path.lineTo(x, y);
      }
    }
    
    path.close();
    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

Don't forget to import dart:math for the trigonometric functions:


import 'dart:math' as math;
// Then use math.cos() and math.sin()

Building a Custom Progress Indicator

One practical application of custom painters is creating unique progress indicators. Here's a circular progress indicator that shows progress as a percentage:


class CircularProgressPainter extends CustomPainter {
  final double progress;
  final Color color;

  CircularProgressPainter({
    required this.progress,
    this.color = Colors.blue,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.width / 2 - 10;
    
    // Background circle
    final backgroundPaint = Paint()
      ..color = Colors.grey[300]!
      ..style = PaintingStyle.stroke
      ..strokeWidth = 8;
    
    canvas.drawCircle(center, radius, backgroundPaint);

    // Progress arc
    final progressPaint = Paint()
      ..color = color
      ..style = PaintingStyle.stroke
      ..strokeWidth = 8
      ..strokeCap = StrokeCap.round;

    final sweepAngle = 2 * 3.14159 * progress;
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      -3.14159 / 2, // Start from top
      sweepAngle,
      false,
      progressPaint,
    );
  }

  @override
  bool shouldRepaint(CircularProgressPainter oldDelegate) {
    return oldDelegate.progress != progress || oldDelegate.color != color;
  }
}

Notice how we've improved the shouldRepaint method to check if the progress or color has changed. This optimization ensures the painter only redraws when necessary.

Adding Animation to Custom Painters

Custom painters become even more powerful when combined with animations. Let's create an animated loading spinner:


class AnimatedSpinnerPainter extends CustomPainter {
  final double rotation;

  AnimatedSpinnerPainter({required this.rotation});

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.width / 2 - 10;
    
    final paint = Paint()
      ..color = Colors.blue
      ..style = PaintingStyle.stroke
      ..strokeWidth = 6
      ..strokeCap = StrokeCap.round;

    // Save canvas state
    canvas.save();
    
    // Rotate canvas
    canvas.translate(center.dx, center.dy);
    canvas.rotate(rotation);
    canvas.translate(-center.dx, -center.dy);

    // Draw arc
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      0,
      3.14159 * 1.5, // 270 degrees
      false,
      paint,
    );

    // Restore canvas state
    canvas.restore();
  }

  @override
  bool shouldRepaint(AnimatedSpinnerPainter oldDelegate) {
    return oldDelegate.rotation != rotation;
  }
}

To animate this painter, use it with an AnimationController:


class AnimatedSpinner extends StatefulWidget {
  @override
  _AnimatedSpinnerState createState() => _AnimatedSpinnerState();
}

class _AnimatedSpinnerState extends State
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: Duration(seconds: 1),
    )..repeat();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return CustomPaint(
          painter: AnimatedSpinnerPainter(
            rotation: _controller.value * 2 * 3.14159,
          ),
          size: Size(50, 50),
        );
      },
    );
  }
}

Performance Considerations

Custom painters can be performance-intensive, especially with complex drawings or animations. Here are some tips to keep your app running smoothly:

  • Optimize shouldRepaint: Always check if the painter actually needs to redraw. Compare the old and new values of properties that affect the drawing.
  • Use RepaintBoundary: Wrap your CustomPaint widget in a RepaintBoundary to isolate repaints and prevent unnecessary rebuilds of parent widgets.
  • Avoid complex calculations in paint: Pre-calculate values outside the paint method when possible.
  • Cache expensive operations: If you're drawing the same thing repeatedly, consider caching the result.

Here's an example of using RepaintBoundary:


RepaintBoundary(
  child: CustomPaint(
    painter: MyCustomPainter(),
    size: Size(200, 200),
  ),
)

Real-World Example: A Simple Line Chart

Let's put everything together and create a simple line chart painter. This demonstrates how custom painters can be used for data visualization:


class LineChartPainter extends CustomPainter {
  final List dataPoints;
  final Color lineColor;
  final Color fillColor;

  LineChartPainter({
    required this.dataPoints,
    this.lineColor = Colors.blue,
    this.fillColor = Colors.blue,
  });

  @override
  void paint(Canvas canvas, Size size) {
    if (dataPoints.isEmpty) return;

    final maxValue = dataPoints.reduce((a, b) => a > b ? a : b);
    final minValue = dataPoints.reduce((a, b) => a < b ? a : b);
    final range = maxValue - minValue;
    
    if (range == 0) return;

    final path = Path();
    final stepX = size.width / (dataPoints.length - 1);

    // Create the line path
    for (int i = 0; i < dataPoints.length; i++) {
      final x = i * stepX;
      final normalizedValue = (dataPoints[i] - minValue) / range;
      final y = size.height - (normalizedValue * size.height);

      if (i == 0) {
        path.moveTo(x, y);
      } else {
        path.lineTo(x, y);
      }
    }

    // Draw filled area under the line
    final fillPath = Path.fromPath(path)
      ..lineTo(size.width, size.height)
      ..lineTo(0, size.height)
      ..close();

    final fillPaint = Paint()
      ..color = fillColor.withOpacity(0.3)
      ..style = PaintingStyle.fill;

    canvas.drawPath(fillPath, fillPaint);

    // Draw the line
    final linePaint = Paint()
      ..color = lineColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2
      ..strokeCap = StrokeCap.round
      ..strokeJoin = StrokeJoin.round;

    canvas.drawPath(path, linePaint);

    // Draw data points
    final pointPaint = Paint()
      ..color = lineColor
      ..style = PaintingStyle.fill;

    for (int i = 0; i < dataPoints.length; i++) {
      final x = i * stepX;
      final normalizedValue = (dataPoints[i] - minValue) / range;
      final y = size.height - (normalizedValue * size.height);
      
      canvas.drawCircle(Offset(x, y), 4, pointPaint);
    }
  }

  @override
  bool shouldRepaint(LineChartPainter oldDelegate) {
    return oldDelegate.dataPoints != dataPoints ||
        oldDelegate.lineColor != lineColor ||
        oldDelegate.fillColor != fillColor;
  }
}

Common Patterns and Best Practices

As you work with custom painters, you'll notice some common patterns emerge:

  • Center-based drawing: Many painters calculate the center point first and draw relative to it, making the design responsive to different sizes.
  • Normalized coordinates: When working with data visualization, normalize your data to fit within the available canvas size.
  • Path reuse: If you're drawing the same path multiple times with different paints, create the path once and reuse it.
  • Canvas transformations: Use canvas.save() and canvas.restore() when applying transformations to avoid affecting subsequent drawings.

Conclusion

Custom Painters open up a world of possibilities in Flutter development. Whether you're creating unique UI elements, building data visualizations, or implementing custom animations, understanding how to use custom painters gives you the flexibility to bring your creative vision to life.

Remember to start simple and gradually build complexity. Practice with basic shapes before moving on to more advanced techniques. And always keep performance in mind—optimize your shouldRepaint methods and use RepaintBoundary when appropriate.

With custom painters in your toolkit, you're well-equipped to create Flutter apps that stand out with unique, beautiful graphics. Happy painting!