You paint a diamond, a star or a blob, wrap it in a GestureDetector, and taps in the empty corners still fire. Flutter's hit testing works on rectangles unless something tells it otherwise, and a CustomPaint doesn't tell it anything by default.

Fixing it takes a little more than overriding hitTest: the obvious override has a bug that only shows up after a rebuild, lines need a different test from filled shapes, and the wrong HitTestBehavior undoes everything. Each behaviour described here is checked in a widget test.

Hit maps: dots coloured by what a hit test returns at each point, for overlapping shapes, Path.contains on a line, and a distance-based stroke test

In the image, each dot is a real hit test at that point. On the left, overlap areas take the colour of the shape painted on top. In the middle, Path.contains on a stroked line reports hits in the empty area under the line and misses the half of the stroke that lies outside that area. On the right, a distance check hits only near the line.


How a tap reaches a CustomPaint

When a pointer goes down, Flutter asks each render object whether the point hits it. For a CustomPaint, the checks run in this order:

  1. Is the point inside the box? If not, it's a miss and nothing else runs. A zero-size CustomPaint can never be hit (see CustomPaint size is zero).
  2. The foreground painter's hitTest. If it returns true, the CustomPaint is hit and the child is skipped.
  3. The child, if there is one.
  4. The background painter's hitTest. If it returns null, the default, the point counts as a hit.

The Flutter docs sum up the defaults: "consider all points to be hits for background painters, and no points to be hits for foreground painters." In practice:

Setup Result for a tap inside the box
painter:, no hitTest override Hit everywhere, including empty corners
foregroundPainter:, no override, no child Never hit
foregroundPainter: with a child Hit wherever the child is hit
Foreground hitTest returns true Hit, and the child never sees the tap
Background hitTest returns false, with a child Still hit where the child is hit

So a plain background painter is the reason taps land in the corners. You fix that by returning false for points outside your shape.


Overriding hitTest, and the trap in caching the size

hitTest(Offset position) receives the point in the painter's own coordinates and returns true, false or null. What it doesn't receive is the size, and most shapes are built from the size. The common workaround is to remember the size from paint() (diamondPath is defined in the next section):

// Don't do this: hitTest only works until the next rebuild.
class CachedSizeDiamondPainter extends CustomPainter {
  CachedSizeDiamondPainter({required this.color});
  final Color color;
  Size _size = Size.zero;

  @override
  void paint(Canvas canvas, Size size) {
    _size = size;
    canvas.drawPath(diamondPath(size), Paint()..color = color);
  }

  @override
  bool? hitTest(Offset position) => diamondPath(_size).contains(position);

  @override
  bool shouldRepaint(CachedSizeDiamondPainter oldDelegate) => oldDelegate.color != color;
}

It works on the first screen and breaks later. Each time the parent rebuilds, CustomPaint gets a new painter instance. If shouldRepaint returns false, Flutter keeps the existing picture and never calls paint() on the new instance, so its _size is still Size.zero. Every hit test now misses.

A widget test shows it directly: a tap in the centre of the diamond fires onTap. Rebuild the same widget with the same colour, tap the same spot, and nothing fires. Because anything else that repaints the area restores _size, the bug in a real app looks intermittent.

The rule: hitTest should only use data the painter received in its constructor, never state written during paint(). There are two clean ways to get there.


One shape: use ClipPath as a tap mask

ClipPath already hit-tests against its path, and its clipper receives the current size on every layout. That makes it a ready-made tap mask:

Path diamondPath(Size size) => Path()
  ..moveTo(size.width / 2, 0)
  ..lineTo(size.width, size.height / 2)
  ..lineTo(size.width / 2, size.height)
  ..lineTo(0, size.height / 2)
  ..close();

class DiamondPainter extends CustomPainter {
  const DiamondPainter({required this.color});
  final Color color;

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawPath(diamondPath(size), Paint()..color = color);
  }

  @override
  bool shouldRepaint(DiamondPainter oldDelegate) => oldDelegate.color != color;
}

class DiamondClipper extends CustomClipper<Path> {
  const DiamondClipper();

  @override
  Path getClip(Size size) => diamondPath(size);

  @override
  bool shouldReclip(DiamondClipper oldClipper) => false;
}

class DiamondButton extends StatelessWidget {
  const DiamondButton({super.key, required this.onTap});
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: const ClipPath(
        clipper: DiamondClipper(),
        clipBehavior: Clip.none, // hit test against the path, but don't clip
        child: CustomPaint(painter: DiamondPainter(color: Colors.indigo)),
      ),
    );
  }
}

Two details make this work:

  • RenderClipPath.hitTest returns false for any point outside the clipper's path, before the child is even asked. Taps in the corners never reach the GestureDetector.
  • With clipBehavior: Clip.none, RenderClipPath.paint paints the child without pushing a clip, so you get the path-accurate hit test without paying for clipping. The painter already draws the exact shape, so there's nothing to clip.

In a test, a tap in the corner of a 200 ร— 200 DiamondButton doesn't fire, a tap in the centre does, and it keeps working after rebuilds. If the child is something that needs cutting to shape, such as a photo, leave clipBehavior at its default Clip.antiAlias. The hit test is the same either way.


Several shapes: which one was tapped?

When one painter draws several shapes, a plain true isn't enough. You need the index of the shape under the finger, and the tap handler needs the same paths the painter drew. Build the paths outside the painter so both can use them:

class Blob {
  const Blob(this.path, this.color);
  final Path path;
  final Color color;
}

int? blobAt(List<Blob> blobs, Offset position) {
  // The last blob painted is on top, so test in reverse paint order.
  for (var i = blobs.length - 1; i >= 0; i--) {
    if (blobs[i].path.contains(position)) return i;
  }
  return null;
}

class BlobPainter extends CustomPainter {
  BlobPainter({required this.blobs, required this.selected});
  final List<Blob> blobs;
  final int? selected;

  @override
  void paint(Canvas canvas, Size size) {
    for (var i = 0; i < blobs.length; i++) {
      canvas.drawPath(blobs[i].path, Paint()..color = blobs[i].color);
      if (i == selected) {
        canvas.drawPath(
          blobs[i].path,
          Paint()
            ..style = PaintingStyle.stroke
            ..strokeWidth = 4
            ..color = const Color(0xFF0F172A),
        );
      }
    }
  }

  @override
  bool? hitTest(Offset position) => blobAt(blobs, position) != null;

  @override
  bool shouldRepaint(BlobPainter oldDelegate) =>
      oldDelegate.blobs != blobs || oldDelegate.selected != selected;
}

buildBlobs(Size size) is whatever builds your shapes; in the example it returns an oval, a rounded rectangle and a triangle that overlap. The widget gets the size at build time from LayoutBuilder, so nothing depends on paint():

class BlobPicker extends StatefulWidget {
  const BlobPicker({super.key, this.onSelected});
  final ValueChanged<int?>? onSelected;

  @override
  State<BlobPicker> createState() => _BlobPickerState();
}

class _BlobPickerState extends State<BlobPicker> {
  Size? _size;
  List<Blob> _blobs = const [];
  int? _selected;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final size = constraints.biggest; // needs a bounded parent
        if (size != _size) {
          _size = size;
          _blobs = buildBlobs(size);
        }
        return GestureDetector(
          onTapUp: (details) {
            setState(() => _selected = blobAt(_blobs, details.localPosition));
            widget.onSelected?.call(_selected);
          },
          child: CustomPaint(
            size: size,
            painter: BlobPainter(blobs: _blobs, selected: _selected),
          ),
        );
      },
    );
  }
}

Things worth knowing about this pattern:

  • Reverse order matters. Where two shapes overlap, the one painted last is the one the user sees, so it must win. In a test on a 300 ร— 200 picker, a tap where the oval and the rectangle overlap selects the rectangle, which is painted second.
  • The list is rebuilt only when the size changes. Keeping the same List instance means shouldRepaint only returns true when something actually changed.
  • details.localPosition is relative to the GestureDetector. It matches the painter's coordinates here because the detector wraps the CustomPaint directly. Put 16 px of Padding between them and a test shows every position shifted by 16.
  • constraints.biggest needs bounds. Give BlobPicker a SizedBox or Expanded, or its size is infinite.

Taps on empty space

Because BlobPainter.hitTest returns false between shapes, a default GestureDetector never hears about those taps. In the test, a tap in an empty corner doesn't call onTapUp at all. That's usually what you want, since the tap can reach whatever is behind the picker.

If an empty tap should clear the selection instead, set behavior: HitTestBehavior.opaque on the GestureDetector. The detector then receives every tap in its box, and blobAt returns null for the empty ones, which a test confirms.


Lines: Path.contains only knows about the fill

Path.contains answers one question: would this point be filled if the path were filled? It knows nothing about strokes. That gives wrong answers for anything drawn with PaintingStyle.stroke:

What you drew Path.contains result
An open polyline (a chart line) true in the empty area the line encloses, false on the part of the stroke outside that area
A stroked circle (a ring outline) true in the empty middle, false on the outer half of the stroke

For an open path, the fill rule closes it with an imaginary straight line from the end back to the start, which is why the middle panel of the image fills the area under the zigzag. For a line chart from (0, 100) to (50, 0) to (100, 100), a test shows contains returns true at (50, 80), where nothing is drawn, and false at (24, 50), which sits under a 4 px stroke.

To hit-test a stroke, measure the distance from the tap to the line:

double distanceToSegment(Offset p, Offset a, Offset b) {
  final ab = b - a;
  final lengthSquared = ab.distanceSquared;
  if (lengthSquared == 0) return (p - a).distance;
  // Project p onto the segment, clamped to its end points.
  final t = (((p.dx - a.dx) * ab.dx + (p.dy - a.dy) * ab.dy) / lengthSquared).clamp(0.0, 1.0);
  return (p - (a + ab * t)).distance;
}

/// True if [p] is within [tolerance] of any part of [path]'s outline.
/// Curves are sampled every [step] logical pixels.
bool strokeContains(Path path, Offset p, double tolerance, {double step = 2}) {
  for (final metric in path.computeMetrics()) {
    if (metric.length == 0) continue;
    var previous = metric.getTangentForOffset(0)!.position;
    for (var d = step; d < metric.length + step; d += step) {
      final current = metric.getTangentForOffset(d)!.position; // d is clamped to length
      if (distanceToSegment(p, previous, current) <= tolerance) return true;
      previous = current;
    }
  }
  return false;
}

distanceToSegment projects the point onto the segment and clamps to the end points, so taps past the end of a line measure to the end, not to an infinite line. strokeContains walks every contour with computeMetrics() and treats the path as a chain of short segments, which handles curves, arcs and closing segments without special cases.

Then use it in the painter, with a tolerance of half the stroke width plus some room for a fingertip:

class SparklinePainter extends CustomPainter {
  SparklinePainter(this.points, {this.strokeWidth = 4});
  final List<Offset> points;
  final double strokeWidth;

  late final Path _path = Path()..addPolygon(points, false);

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawPath(
      _path,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = strokeWidth
        ..strokeJoin = StrokeJoin.round
        ..strokeCap = StrokeCap.round
        ..color = const Color(0xFF6366F1),
    );
  }

  // Path.contains would test the area the open polyline encloses, not the line.
  @override
  bool? hitTest(Offset position) => strokeContains(_path, position, strokeWidth / 2 + 8);

  @override
  bool shouldRepaint(SparklinePainter oldDelegate) =>
      !listEquals(oldDelegate.points, points) || oldDelegate.strokeWidth != strokeWidth;
}

The points come in through the constructor, so hitTest has everything it needs without caching anything from paint(). (listEquals comes from package:flutter/foundation.dart.) In a test, a tap 7 px from the line fires, and a tap in the enclosed area doesn't.


HitTestBehavior can undo all of this

GestureDetector.behavior decides what the detector does with taps that its child doesn't claim:

  • deferToChild: the detector only receives a tap if its child is hit. This is the default when the detector has a child. (Without a child, GestureDetector defaults to translucent.)
  • opaque: the detector receives every tap inside its own rectangle and blocks widgets behind it.
  • translucent: the detector receives every tap inside its rectangle and also lets widgets behind it receive the pointer.
GestureDetector(
  behavior: HitTestBehavior.opaque, // or translucent: the empty corners now fire too
  onTap: onTap,
  child: const ClipPath(
    clipper: DiamondClipper(),
    child: ColoredBox(color: Colors.indigo),
  ),
)

Here is what a widget test records with a diamond-shaped detector in front of a full-size background that has both a raw Listener and its own GestureDetector:

behavior on the diamond Tap in a corner, outside the diamond Tap inside the diamond
deferToChild (default) Background gets the pointer and its onTap Diamond onTap only
opaque Diamond onTap only Diamond onTap only
translucent Diamond onTap; background Listener sees the pointer, but its onTap doesn't fire Diamond onTap only

Both opaque and translucent make the whole rectangle tappable, which defeats the shape. The translucent row has one more subtlety: both tap recognisers enter the gesture arena, and when neither claims the gesture, the arena gives it to the first member, which is the front detector. Only raw pointer listeners behind it see the event.

If your shape should define what's tappable, leave behavior alone.


Checklist

  • One shape: wrap the painter in ClipPath(clipper: ..., clipBehavior: Clip.none) and let the clipper do the hit test.
  • Several shapes: build the paths outside paint(), share them with the tap handler, and test in reverse paint order.
  • Lines and outlines: test distance to the stroke, not Path.contains.
  • Never read state written in paint() from hitTest(). A new painter instance may never be painted.
  • Leave HitTestBehavior at the default unless you want the whole rectangle to respond.
  • Zero-size widgets are never hit. If nothing responds at all, check the size first.

Related articles