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:
- Custom charts and graphs
- Unique progress indicators
- Signature pads
- Drawing apps
- Custom shape clipper effects
- Particle systems and animations
- Game graphics
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:
CustomPainteris where all drawing logic livespaint()receives aCanvasandSizeto work withshouldRepaint()determines when to redraw (optimize performance)- Canvas methods:
drawCircle,drawArc,drawPath,drawLine, etc. - Use
Paintobjects to define colors, styles, and effects - Combine with
AnimatedBuilderfor smooth animations
Try It Yourself
Create a custom wave loader animation using CustomPaint:
- Create a
WavePainterthat draws a sine wave - Use
AnimationControllerto animate the wave’s horizontal offset - Draw the wave using
Path.lineTo()to create the sine curve - Fill below the wave with a gradient
- 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.