Problem
I am building a Widget that draws objects where I would like to allow panning+zooming while also detecting when taps/clicks overlap with objects drawn on the screen.
The problem is that after panning or zooming, the taps are registered local to the initial frame, causing a significat offset to the actual cursor position, and making it impossible to tap on anything that is outside the original frame.
Debug screenshots
Before zoom
In the screenshots, I’ve but a green box around the initial frame and a red dot where the cursor tap is being registered. Note that before zooming everything is as expected and the the tap on the cirlce is detected (you can see the “Node tapped” in terminal output).
After zoom
After zoom the tap position looks to be local to the orginal (zoomed-out) frame, is totally offset from the cursor, and the circle under the cursor is not detected as a hit. In fact it can never be hit because it’s outside the green box representing the original draw frame.
Code
import 'package:flutter/material.dart';
import 'dart:math';
class GraphWidget extends StatefulWidget {
@override
_GraphWidgetState createState() => _GraphWidgetState();
}
class _GraphWidgetState extends State<GraphWidget> {
List<Offset> nodes = [];
EdgeInsets boundaryMargin = EdgeInsets.all(20.0);
Offset? tapPosition;
@override
void initState() {
super.initState();
_generateRandomNodes();
}
void _generateRandomNodes() {
final random = Random();
nodes = List.generate(10, (index) {
return Offset(
random.nextDouble() * 2000,
random.nextDouble() * 2000,
);
});
setState(() {
_calculateBoundaryMargin();
});
}
void _calculateBoundaryMargin() {
if (nodes.isEmpty) {
return;
}
double minX = nodes.map((node) => node.dx).reduce((a, b) => a < b ? a : b);
double maxX = nodes.map((node) => node.dx).reduce((a, b) => a > b ? a : b);
double minY = nodes.map((node) => node.dy).reduce((a, b) => a < b ? a : b);
double maxY = nodes.map((node) => node.dy).reduce((a, b) => a > b ? a : b);
double marginX = (maxX - minX) / 2;
double marginY = (maxY - minY) / 2;
setState(() {
boundaryMargin = EdgeInsets.only(
left: -minX + marginX,
right: maxX + marginX,
top: -minY + marginY,
bottom: maxY + marginY,
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Graph'),
),
body: LayoutBuilder(
builder: (context, constraints) {
return GestureDetector(
onTapUp: (details) {
final RenderBox box = context.findRenderObject() as RenderBox;
final Offset localOffset = box.globalToLocal(details.globalPosition);
setState(() {
tapPosition = localOffset;
});
_onCanvasTap(localOffset);
},
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.red),
),
child: InteractiveViewer(
clipBehavior: Clip.none,
boundaryMargin: boundaryMargin,
minScale: 0.1,
maxScale: 4.0,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.green),
),
width: constraints.maxWidth * 2,
height: constraints.maxHeight * 2,
child: CustomPaint(
size: Size(constraints.maxWidth * 2, constraints.maxHeight * 2),
painter: GraphPainter(
nodes,
tapPosition: tapPosition,
),
),
),
),
),
);
},
),
);
}
void _onCanvasTap(Offset position) {
print(position);
for (final node in nodes) {
if ((position - node).distance <= 20) {
print('Node tapped at $node');
break;
}
}
}
}
class GraphPainter extends CustomPainter {
final List<Offset> nodes;
final Offset? tapPosition;
GraphPainter(this.nodes, {this.tapPosition});
@override
void paint(Canvas canvas, Size size) {
final nodePaint = Paint()
..color = Colors.green
..style = PaintingStyle.fill;
for (final node in nodes) {
canvas.drawCircle(node, 100, nodePaint);
}
if (tapPosition != null) {
final tapPaint = Paint()
..color = Colors.red
..style = PaintingStyle.fill;
canvas.drawCircle(tapPosition!, 15, tapPaint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
void main() {
runApp(MaterialApp(
home: GraphWidget(),
));
}