Flutter CustomPaint and GestureDetector: Draw Custom Widgets and Handle Touch Input

Most Flutter developers spend their time composing existing widgets — Container, Row, Column, Card. That's fine for the majority of UIs. But at some point, you'll get a design that no combination of built-in widgets can produce cleanly. A custom shape, a hand-drawn arc, an interactive canvas where users can sketch.
That's where CustomPaint and GestureDetector come in. Together, they let you draw anything and respond to any touch input.
What CustomPaint Actually Does
CustomPaint is Flutter's drawing surface. It gives your code direct access to a Canvas object — the same drawing API that Flutter itself uses internally. With it, you can draw lines, arcs, bezier curves, rectangles, circles, gradients, and arbitrary paths.
The key class is CustomPainter. You subclass it, implement two methods, and pass an instance to CustomPaint:
class CirclePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.deepPurple
..style = PaintingStyle.fill;
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width * 0.4;
canvas.drawCircle(center, radius, paint);
}
@override
bool shouldRepaint(CirclePainter oldDelegate) => false;
}
Using it:
CustomPaint(
painter: CirclePainter(),
size: const Size(300, 300),
)
shouldRepaint determines whether Flutter redraws when the widget rebuilds. Return false for static visuals; return true (or compare old and new delegate properties) for anything that changes over time.
Drawing More Complex Shapes with Path
The Path class is the most powerful drawing tool in the Canvas API. It lets you describe any shape as a sequence of moves, lines, and curves.
class WavePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.teal
..style = PaintingStyle.fill;
final path = Path()
..moveTo(0, size.height * 0.6)
..quadraticBezierTo(
size.width * 0.25, size.height * 0.45,
size.width * 0.5, size.height * 0.6,
)
..quadraticBezierTo(
size.width * 0.75, size.height * 0.75,
size.width, size.height * 0.6,
)
..lineTo(size.width, size.height)
..lineTo(0, size.height)
..close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(WavePainter oldDelegate) => false;
}
quadraticBezierTo takes a control point and an end point, producing a smooth curve. Chain multiple curves together to get a wave, a mountain outline, a custom app bar shape — anything your design requires.
Making It Interactive with GestureDetector
A static drawing is useful, but the real power comes from combining CustomPaint with GestureDetector. You can let users draw, tap elements, or interact with anything on the canvas.
Here's a simple free-draw canvas — users drag their finger and it draws a stroke:
class DrawingCanvas extends StatefulWidget {
const DrawingCanvas({super.key});
@override
State<DrawingCanvas> createState() => _DrawingCanvasState();
}
class _DrawingCanvasState extends State<DrawingCanvas> {
final List<Offset?> _points = [];
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanUpdate: (details) {
setState(() {
final box = context.findRenderObject() as RenderBox;
_points.add(box.globalToLocal(details.globalPosition));
});
},
onPanEnd: (_) {
setState(() => _points.add(null)); // null signals a break between strokes
},
child: CustomPaint(
painter: StrokePainter(_points),
size: Size.infinite,
),
);
}
}
class StrokePainter extends CustomPainter {
final List<Offset?> points;
StrokePainter(this.points);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.black
..strokeWidth = 3.0
..strokeCap = StrokeCap.round;
for (int i = 0; i < points.length - 1; i++) {
if (points[i] != null && points[i + 1] != null) {
canvas.drawLine(points[i]!, points[i + 1]!, paint);
}
}
}
@override
bool shouldRepaint(StrokePainter oldDelegate) => oldDelegate.points != points;
}
A few things to note here: onPanUpdate fires continuously as the finger moves, providing position updates. globalToLocal converts screen coordinates to canvas-local coordinates, which is necessary when the canvas isn't at the top-left of the screen. The null sentinel between strokes means lifting and putting down the finger starts a new stroke.
Hit Testing: Knowing What Was Tapped
Sometimes you don't want to respond to taps on the whole canvas — you want to detect when the user taps a specific thing you drew. For that, CustomPainter has a hitTest method.
class TappableCirclePainter extends CustomPainter {
final Offset center;
final double radius;
TappableCirclePainter({required this.center, required this.radius});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = Colors.orange;
canvas.drawCircle(center, radius, paint);
}
@override
bool hitTest(Offset position) {
return (position - center).distance <= radius;
}
@override
bool shouldRepaint(TappableCirclePainter oldDelegate) =>
oldDelegate.center != center || oldDelegate.radius != radius;
}
When hitTest returns true, the GestureDetector wrapping this CustomPaint will fire its tap callbacks. When it returns false, the tap passes through as if nothing was there. This gives you precise, geometry-aware hit detection on anything you've drawn.
Animating Your Drawings
Static drawings are useful; animated ones are memorable. The standard approach is to drive a CustomPainter from an AnimationController:
class PulsingCircle extends StatefulWidget {
const PulsingCircle({super.key});
@override
State<PulsingCircle> createState() => _PulsingCircleState();
}
class _PulsingCircleState extends State<PulsingCircle>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _radiusAnim;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
)..repeat(reverse: true);
_radiusAnim = Tween<double>(begin: 40, end: 80).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _radiusAnim,
builder: (context, _) {
return CustomPaint(
painter: CircleRadiusPainter(radius: _radiusAnim.value),
size: const Size(200, 200),
);
},
);
}
}
class CircleRadiusPainter extends CustomPainter {
final double radius;
CircleRadiusPainter({required this.radius});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
canvas.drawCircle(center, radius, Paint()..color = Colors.indigo);
}
@override
bool shouldRepaint(CircleRadiusPainter old) => old.radius != radius;
}
AnimatedBuilder rebuilds only the subtree it wraps when the animation ticks — efficient and clean. Pass the animated value as a parameter to your painter, and return true from shouldRepaint whenever it changes.
Performance Considerations
A few things to keep in mind as your custom painters get more complex:
shouldRepaint is critical. Every time Flutter rebuilds the parent widget, it calls shouldRepaint. If you return true unconditionally, your painter redraws on every build — which is expensive. Compare the old and new delegate properties and return false when nothing meaningful has changed.
Use repaintBoundary for complex painters. Wrapping a CustomPaint in a RepaintBoundary widget tells Flutter to cache its layer separately, so it doesn't get redrawn when unrelated parts of the tree rebuild.
Avoid allocating Paint and Path objects inside paint(). These objects get created on every frame if they're constructed inside the method. Create them as fields on the painter (or use const where possible) and reuse them.
What You Can Build With This
- Custom progress indicators — arcs, segmented rings, waveform-style bars
- Interactive drawing and annotation tools
- Chart and data visualization components
- Custom onboarding illustrations that animate into view
- Game-like UI elements with touch interaction
The Canvas API is lower-level than widgets, but it's not complicated once you get the mental model: you describe what to draw in paint(), control when it redraws with shouldRepaint(), and wire up interaction through GestureDetector or hitTest. That's really the whole thing.




