Detecting Clicks On Overlapping CustomPaint Widgets

This post will describe how to detect which figure is clicked when there are multiple overlapping figures. We are going to start by showing how to do this for three overlapping figures. After that, we are going to apply it to the hexagon grid from the previous post.

Detecting clicks on three overlapping figures

So instead of hexagons, let’s draw something else. We are going to draw three circles, so for this circle, we need a new CustomPainter. As described in the last post we have to implement the paint and shouldRepaint method.

class CirclePainter extends CustomPainter {
  final double radius;
  final Offset center;
  final Color color;

  CirclePainter(this.center, this.radius, this.color);

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()..color = color;
    canvas.drawCircle(center, radius, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

This CustomPainter can be used by the CustomPaint widget and since we again put them in a Stacked widget, they can be overlapping. The following code snippet will show three circles with the red and yellow circles on top of the blue circle.

class HexagonGridDemo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('Hexagon Grid Demo'),
        ),
        body: Stack(
          children: <Widget>[
            CustomPaint(
              painter: CirclePainter(Offset(90, 120), 70, Colors.blue),
            ),
            CustomPaint(
              painter: CirclePainter(Offset(60, 60), 50, Colors.red),
            ),
            CustomPaint(
              painter: CirclePainter(Offset(140, 70), 40, Colors.yellow),
            )
          ],
        ),
      ),
    );
  }
}

Adding a Listener

So how can we detect on which of the circles is clicked? First of all, we need to add a key to each circle. The key is used to find the rendered object of that Widget. We are also going to add a Listener Widget above the Stack Widget to listen to different click events. The full list of possible clicks and touches can be found in the Flutter docs. So we are going to implement the onPointerDown method. So then our main.dart is changed into the following:

class HexagonGridDemo extends StatelessWidget {
  final GlobalKey blueCircle = new GlobalKey();
  final GlobalKey redCircle = new GlobalKey();
  final GlobalKey yellowCircle = new GlobalKey();
  final result = BoxHitTestResult();

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('Hexagon Grid Demo'),
        ),
        body: Listener(
          onPointerDown: (PointerEvent pointerEvent) {
            handleClick(pointerEvent);
          },
          child: Stack(
            children: <Widget>[
              CustomPaint(
                key: blueCircle,
                painter: CirclePainter(Offset(90, 120), 70, Colors.blue),
                child: Container(),
              ),
              CustomPaint(
                key: redCircle,
                painter: CirclePainter(Offset(60, 60), 50, Colors.red),
                child: Container(),
              ),
              CustomPaint(
                key: yellowCircle,
                painter: CirclePainter(Offset(140, 70), 40, Colors.yellow),
                child: Container(),
              )
            ],
          ),
        ),
      ),
    );
  }

  handleClick(final PointerEvent pointerEvent) {
    // detect click on circles
  }
}

So now it is time to implement the handleClick method. We use a helper that does all the magic. The isClicked returns true if the clicks were in the CustomPaint of the corresponding GlobalKey. From the GlobalKey we can use the currentContext.findRenderObject();. This will return the RenderBox of the CustomPaint widget. So now we can use the hitTest on the RenderBox. Before we do that we have to translate the position of the Listener to a local position. This can be done with the globalToLocal method on the RenderBox. So the global point (40,120) will return the local point (40,40). The x coordinate remains the same since we have no other widgets horizontally. The y coordinate is subtracted by 80. This is because there is an AppBar above the Canvas. In this case, we could also use the localPosition of the details, since all CustomPaint elements are drawn in the same Canvas.

handleClick(PointerEvent details) {
  if (isClicked(details, redCircle)) {
    print("clicked the red circle");
  }
  if (isClicked(details, blueCircle)) {
    print("clicked the blue circle");
  }
  if (isClicked(details, yellowCircle)) {
    print("clicked the yellow circle");
  }
}

bool isClicked(final PointerEvent details, final GlobalKey key) {
  final RenderBox circleBox = key.currentContext.findRenderObject();
  Offset localClick = circleBox.globalToLocal(details.position);
//    Offset localClick = details.localPosition
  if (circleBox.hitTest(result, position: localClick)) {
    return true;
  }
  return false;
}

So if we would try this out, you will probably see the following on each click:

flutter<span class="token punctuation">:</span> clicked the red circle
flutter<span class="token punctuation">:</span> clicked the blue circle
flutter<span class="token punctuation">:</span> clicked the yellow circle

Implementing the hitTest

This is because we have to do one more thing. Our CustomPainter has to implement the hitTest method. The hitTest method has as input an Offset position. We can use the contains method of the path to determine if it is in or outside the circle. We can create a path of the circle with the fromCircle method of the Rect. It expects the center and the radius of the circle, which we already know because we needed them to draw the circle.

@override
bool hitTest(Offset position) {
  final Path path = new Path();
  path.addRect(Rect.fromCircle(center: center, radius: radius));
  return path.contains(position);
}

So after this method is implemented, the clicked on should print each circle that is clicked. You can try it out for yourself in this Dartpad. Just a reminder, the prints will be shown in the console in the bottom left corner. 

Detecting clicks on a list of CustomPaint widgets

So how do we integrate this into our Hexagon Grid we created in our last post.
We again have to the following things:

  • Implement the hitTest for our HexagonPainter
  • Add GlobalKeys to our CustomPaint widgets
  • Add a Listener on the Stacked widget
  • Iterate over the CustomPaint widgets to detect which Hexagon is clicked

Implementing the hitTest

So let’s start with implementing the hitTest. Since we already create a hexagon path in the paint method, we can reuse that for the hitTest. We get an Offset position, create a path, and use the contains method on the path.

class HexagonPainter extends CustomPainter {
  static const int SIDES_OF_HEXAGON = 6;
  final double radius;
  final Offset center;

  HexagonPainter(this.center, this.radius);

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()..color = Colors.blue;
    Path path = createHexagonPath();
    canvas.drawPath(path, paint);
  }

  Path createHexagonPath() {
    final path = Path();
    var angle = (math.pi * 2) / SIDES_OF_HEXAGON;
    Offset firstPoint = Offset(radius * math.cos(0.0), radius * math.sin(0.0));
    path.moveTo(firstPoint.dx + center.dx, firstPoint.dy + center.dy);
    for (int i = 1; i <= SIDES_OF_HEXAGON; i++) {
      double x = radius * math.cos(angle * i) + center.dx;
      double y = radius * math.sin(angle * i) + center.dy;
      path.lineTo(x, y);
    }
    path.close();
    return path;
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;

  @override
  bool hitTest(Offset position) {
    final Path path = createHexagonPath();
    return path.contains(position);
  }
}

Add GlobalKeys to our CustomPaint widgets

To add GlobalKeys for each of our CustomPaint widgets, we extracted a HexagonModel for the variables needed in the HexagonPaint Widget. This contains the current variables: center, radius. It also creates a new GlobalKey. The constructor of the HexagonPaint now expects a HexagonModel. We add the key of the model to the CustomPaint widget.

class HexagonModel {
  final Offset center;
  final double radius;
  final GlobalKey key = GlobalKey();
  HexagonModel(this.center, this.radius);
}

class HexagonPaint extends StatelessWidget {
  final HexagonModel model;

  HexagonPaint(this.model);

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      key: model.key,
      painter: HexagonPainter(model.center, model.radius),
      child: Container(),
    );
  }
}

Add a Listener on the Stacked widget

Just like the circles, we added a Listener above the Container with all the Paint widgets. The HexagonGrid is now passed to the container and the grid will display the list of HexagonPaint Widgets.

@override
Widget build(BuildContext context) {
  return MaterialApp(
    home: Scaffold(
      appBar: AppBar(
        title: Text('Hexagon Grid Demo'),
      ),
      body: Container(
        color: Colors.grey[200],
        padding: EdgeInsets.all(8),
        child: LayoutBuilder(builder: (context, constraints) {
          grid.initialize(constraints.maxWidth, constraints.maxHeight);
          return Listener(
            onPointerDown: (PointerEvent details) {
              handleClick(details);
            },
            child: Container(
              width: constraints.maxWidth,
              height: constraints.maxHeight,
              color: Colors.transparent,
              child: grid,
            ),
          );
        }),
      ),
    ),
  );
}

Iterate over the CustomPaint widgets to detect which Hexagon is clicked

The handleClick now iterates over the HexagonPaints and passes the HexagonPaint to the determineClick. The method determineClick is almost the same as in the first example. The only thing that has changed is that we now get the key to the model of the HexagonPaint Widget instead of a variable in the Class. We used the firstWhere to detect which Hexagon is clicked, since none of the Hexagons are overlapping each other.

handleClick(PointerEvent details) {
  var hexagon =
      grid.hexagons.firstWhere((hexagon) => determineClick(hexagon, details));
}

bool determineClick(HexagonPaint hexagon, PointerEvent details) {
  final RenderBox hexagonBox =
      hexagon.model.key.currentContext.findRenderObject();
  final result = BoxHitTestResult();
  if (hexagonBox.hitTest(result, position: details.localPosition)) {
    return true;
  }
  return false;
}

Again we put the full code here in a Dartpad, so you can play around with the code without having a full setup. Should you still have any questions, feel free to ask them!

Leave a Reply