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

Search for a command to run...

No comments yet. Be the first to comment.
The build-time binary trick that lets you ship a working feature while keeping the real logic unreadable.

TL;DR — This guide covers everything you need to rebrand a Flutter app for a new client: changing the app name, swapping the icon with flutter_launcher_icons, renaming the package ID with change_app_p

A Quick Story to Set the Scene Meet Lumi and Nyx. They share one Flutter app. Lumi prefers light mode — bright cards, cheerful blues, a UI that feels open. Nyx wants the opposite — calm grays, a dark background, something easier on the eyes at night....

There's a category of bug that only shows up in production apps — the kind that doesn't crash, doesn't throw an exception, and doesn't even look wrong on the surface. It just quietly does the wrong thing. One of the most common in security-sensitive ...

When Classes Start Misbehaving At some point in every Dart project, you run into a situation where your classes are being used in ways you never intended. Someone creates an object directly when there should only ever be one. Someone calls an interna...

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.
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.
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.
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.
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.
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.
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.
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.