Mastering Custom Paint for Beautiful UI Elements

Mastering Custom Paint for Beautiful UI Elements

What You’ll Learn

How to use Flutter’s CustomPaint widget and CustomPainter class to draw custom graphics, shapes, and visual effects that go beyond standard widgets. Perfect for creating unique UI elements, data visualizations, and animations.

Why CustomPaint?

Flutter provides hundreds of widgets, but sometimes you need something truly custom—a unique chart, a custom progress indicator, or a distinctive design element that doesn’t exist as a widget. That’s where CustomPaint comes in.

CustomPaint gives you direct access to the Canvas API, letting you draw shapes, paths, images, and text at a low level. It’s the same API Flutter uses internally to render all widgets, so you’re working with the engine’s core rendering capabilities.

Common use cases:

Example: Custom Progress Ring

Let’s build a circular progress ring with gradient colors—something you’d see in fitness apps:

import 'package:flutter/material.dart';
import 'dart:math' as math;

class ProgressRingPainter extends CustomPainter {
  final double progress; // 0.0 to 1.0
  final Color startColor;
  final Color endColor;
  final double strokeWidth;

  ProgressRingPainter({
    required this.progress,
    this.startColor = Colors.blue,
    this.endColor = Colors.purple,
    this.strokeWidth = 12.0,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = (size.width - strokeWidth) / 2;
    
    // Background circle (gray track)
    final backgroundPaint = Paint()
      ..color = Colors.grey.shade300
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.round;
    
    canvas.drawCircle(center, radius, backgroundPaint);
    
    // Progress arc with gradient
    final rect = Rect.fromCircle(center: center, radius: radius);
    final gradient = SweepGradient(
      colors: [startColor, endColor],
      startAngle: -math.pi / 2,
      endAngle: -math.pi / 2 + (2 * math.pi * progress),
    );
    
    final progressPaint = Paint()
      ..shader = gradient.createShader(rect)
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.round;
    
    // Draw arc from top (-90 degrees) clockwise
    canvas.drawArc(
      rect,
      -math.pi / 2, // Start angle (top)
      2 * math.pi * progress, // Sweep angle
      false, // Don't close the arc
      progressPaint,
    );
  }

  @override
  bool shouldRepaint(covariant ProgressRingPainter oldDelegate) {
    // Only repaint if progress changes
    return oldDelegate.progress != progress ||
           oldDelegate.startColor != startColor ||
           oldDelegate.endColor != endColor;
  }
}

// Usage in a widget
class ProgressRingWidget extends StatelessWidget {
  final double progress;
  final double size;

  const ProgressRingWidget({
    Key? key,
    required this.progress,
    this.size = 120.0,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        SizedBox(
          width: size,
          height: size,
          child: CustomPaint(
            painter: ProgressRingPainter(
              progress: progress.clamp(0.0, 1.0),
              startColor: Colors.cyan,
              endColor: Colors.purple,
            ),
            child: Center(
              child: Text(
                '${(progress * 100).toInt()}%',
                style: TextStyle(
                  fontSize: 24,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
          ),
        ),
      ],
    );
  }
}

// Demo with animation
class ProgressRingDemo extends StatefulWidget {
  @override
  _ProgressRingDemoState createState() => _ProgressRingDemoState();
}

class _ProgressRingDemoState extends State<ProgressRingDemo>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    );
    
    _animation = Tween<double>(begin: 0.0, end: 0.75).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
    );
    
    _controller.forward();
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Custom Progress Ring')),
      body: Center(
        child: AnimatedBuilder(
          animation: _animation,
          builder: (context, child) {
            return ProgressRingWidget(
              progress: _animation.value,
              size: 150,
            );
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          if (_controller.isCompleted) {
            _controller.reverse();
          } else {
            _controller.forward();
          }
        },
        child: Icon(Icons.refresh),
      ),
    );
  }
}

Key concepts:

Try It Yourself

Create a custom wave loader animation using CustomPaint:

  1. Create a WavePainter that draws a sine wave
  2. Use AnimationController to animate the wave’s horizontal offset
  3. Draw the wave using Path.lineTo() to create the sine curve
  4. Fill below the wave with a gradient
  5. Make multiple waves with different amplitudes and speeds

Bonus challenge: Add a clipping effect where the wave fills up a container from bottom to top, like a loading animation.

Tip of the Day

Performance optimization: When using CustomPaint with animations, always implement shouldRepaint() correctly. Return true only when the painting actually needs to change. This prevents unnecessary redraws and keeps your animations at 60fps:

@override
bool shouldRepaint(covariant MyCustomPainter oldDelegate) {
  // Only repaint if values that affect drawing have changed
  return oldDelegate.animationValue != animationValue;
}

For complex painters, consider caching static elements using Canvas.saveLayer() and only redrawing the animated parts. This can dramatically improve performance for intricate custom graphics.