# 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`:

```dart
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:

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

```dart
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:

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

```dart
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`:

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

