I am developing a node editor sort of app, in which you can add nodes and connect them with a noodle connection (like in blender and Unreal engine), I am facing a problem when a node is dragged off screen, and then I pan the screen to it, then when i try to drag from the output circle (to create a new connection line) the line endpoint is not at the same place as my finger gesture (using onpanupdate details.globalposition)
class MyMindMapWidgetState extends State<MyMindMap>
with WidgetsBindingObserver {
double _scale = 1.0;
Offset _offset = Offset.zero;
Offset _initialFocalPoint = Offset.zero;
Offset _offsetOnScaleStart = Offset.zero;
Offset _toolbarOffset = Offset.zero;
double _toolBarScale = 1;
Offset nodeOffset = Offset.zero;
Offset outputOffset = Offset.zero;
Offset inputOffset = Offset.zero;
final bool _staticBackground = false;
void _handleScaleStart(ScaleStartDetails details) {
setState(() {
_initialFocalPoint = details.localFocalPoint;
_offsetOnScaleStart = _offset;
});
}
void _handleScaleUpdate(ScaleUpdateDetails details) {
final double newScale = _scale * details.scale;
late double sensitivity = 0.05;
final double scaleDelta = (newScale - _scale) * sensitivity;
final double clampedScale = (_scale + scaleDelta).clamp(0.3, 3);
// Calculate the normalized offset
final Offset normalizedOffset =
(_initialFocalPoint - _offsetOnScaleStart) / _scale;
setState(() {
_scale = clampedScale;
_offset = details.localFocalPoint - normalizedOffset * _scale;
});
}
void _resetOffsetAndScale() {
setState(() {
_scale = 1.0;
_offset = Offset.zero;
_toolbarOffset = Offset.zero;
_toolBarScale = 1;
});
}
@override
void initState() {
super.initState();
// Call the function to make the app full screen
fullScreenMode();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
// Reset the system UI mode when the widget is disposed
fullScreenMode();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) {
fullScreenMode();
return BlocListener<NodeConnectionCubit, NodeConnectionState>(
listener: (context, state) {
if (state is NodeConnectionUpdatedState) {
setState(() {});
}
},
child: Container(
color: const Color.fromARGB(255, 26, 26, 23),
child: GestureDetector(
onScaleStart: _handleScaleStart,
onScaleUpdate: _handleScaleUpdate,
onDoubleTap: _resetOffsetAndScale,
child: CustomPaint(
painter: !_staticBackground
? DynamicBackground(_offset)
: StaticBackground(),
child: BlocBuilder<NodeCubit, NodeState>(
builder: (context, state) {
if (state is NodeUpdatedState) {
return BlocBuilder<NodeConnectionCubit, NodeConnectionState>(
builder: (context, connectionState) {
if (connectionState is NodeConnectionUpdatedState) {
return Stack(
fit: StackFit.expand,
children: [
Transform(
transform:
Matrix4.diagonal3Values(_scale, _scale, 1.0)
..translate(_offset.dx, _offset.dy),
child: DeferredPointerHandler(
child: DeferPointer(
child: Stack(
children: [
for (int paintIndex = 0;
paintIndex <
connectionState
.linkedNodes.length;
paintIndex++)
CustomPaint(
painter: NodeLinkPainter(
color: Colors.teal.withOpacity(0.5),
connection: connectionState
.linkedNodes[paintIndex],
repaint: context
.watch<NodeConnectionCubit>(),
),
child:
Container(), // This is crucial for CustomPaint to take effect
),
for (int index = 0;
index <
context
.read<NodeCubit>()
.nodeList
.length;
index++)
MyNode(
index: index,
),
],
),
),
),
),
class MyNodeState extends State<MyNode> {
@override
Widget build(BuildContext context) {
final NodeCubit nodeCubit = context.watch<NodeCubit>();
final NodeConnectionCubit connectionCubit =
context.read<NodeConnectionCubit>();
Offset widgetOffset = nodeCubit.getOffsets()[widget.index!];
late String? inputID = nodeCubit.nodeList[widget.index!].inputId;
final NodeUtils nodeUtils = NodeUtils();
return BlocBuilder<NodeCubit, NodeState>(
builder: (context, state) {
if (state is NodeUpdatedState) {
// list of nodes
final nodeList = state.nodes;
final node = nodeList[widget.index!];
final inputOffsets = nodeList.map((e) => e.nodeInput).toList();
Offset? inputOffset;
return Transform.translate(
filterQuality: FilterQuality.high,
offset: widgetOffset,
child: DeferPointer(
child: GestureDetector(
onPanUpdate: (details) {
setState(() {
// Update _widgetOffset with current drag position
widgetOffset = Offset(widgetOffset.dx + details.delta.dx,
widgetOffset.dy + details.delta.dy);
context
.read<NodeCubit>()
.updateOffset(node.id, widgetOffset);
var outputOffset = node.nodeOutput;
connectionCubit.linkedNodes.forEach((e) {
if (node.id == e.outputId) {
context.read<NodeConnectionCubit>().updateConnection(
node.id, outputOffset, node.id, inputOffset);
}
});
});
},
child: BlocBuilder<NodeConnectionCubit, NodeConnectionState>(
builder: (context, connectionState) {
if (connectionState is NodeConnectionUpdatedState) {
// var connections = connectionState.linkedNodes;
return Stack(
alignment: AlignmentDirectional.center,
children: [
//Node Base Color and Style.
Container(
height: node.height?.toDouble(),
width: node.width?.toDouble(),
decoration: BoxDecoration(
// color: Color(0xFF212121).withAlpha(240),
boxShadow: [
BoxShadow(color: Colors.grey.withOpacity(0.1))
],
border: Border.all(
color: Theme.of(context)
.colorScheme
.surface
.withOpacity(0.025),
strokeAlign: BorderSide.strokeAlignInside,
width: 1,
),
borderRadius: BorderRadius.circular(2),
),
child: Column(
children: [
Expanded(
flex: 2,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context)
.cardColor
.withOpacity(0.95),
borderRadius: const BorderRadius.all(
Radius.circular(2))),
)),
],
),
),
//Node Output
GestureDetector(
onPanStart: (details) {
setState(() {
inputOffset = details.globalPosition;
print("starting at ... $inputOffset");
final existingConnections = connectionCubit
.linkedNodes
.where((connection) =>
connection.outputId == node.id)
.toList();
var outputOffset = nodeCubit
.getOutputOffsets(node.id)[widget.index!];
if (existingConnections.isNotEmpty) {
// ignore: unused_local_variable
for (var connection in existingConnections) {
connectionCubit.updateConnection(node.id,
outputOffset, inputID, inputOffset);
}
} else {
// Add new connection if none exist
connectionCubit.addConnection(node.id,
outputOffset, inputID, inputOffset);
}
});
},
onPanUpdate: (details) {
setState(() {
inputOffset = details.globalPosition;
late final snapOffset =
nodeUtils.snapToClosestOffset(
inputOffset!, inputOffsets, 20);
if (snapOffset != null) {
inputOffset = snapOffset;
} else {
inputOffset = details.globalPosition;
}
final existingConnections = connectionCubit
.linkedNodes
.where((connection) =>
connection.outputId == node.id)
.toList();
var outputOffset = nodeCubit
.getOutputOffsets(node.id)[widget.index!];
if (existingConnections.isNotEmpty) {
// ignore: unused_local_variable
for (var connection in existingConnections) {
connectionCubit.updateConnection(node.id,
outputOffset, inputID, inputOffset);
}
} else {
// Add new connection if none exist
connectionCubit.addConnection(node.id,
outputOffset, inputID, inputOffset);
}
print(
'this is the updated outputOffset ${inputOffsets}');
});
},
child: NodeInputOutput(
alignment: AlignmentDirectional.centerEnd,
nodeheight: node.height!.toDouble(),
nodeWidth: node.width!.toDouble() + 10,
dotHeight: 10,
dotWidth: 10,
offsetX: 0,
// offsetY: -24,
borderWidth: 0.5,
),
),
...etc
void addNode(String? id, String? label, Offset? nodeOffset, Offset? nodeInput,
Offset? nodeOutput, int? height, int? width, String? type, bool? isDone) {
String defaultLabel = "Node ${nodeList.length + 1}";
label ??= defaultLabel;
height ??= 80;
width ??= 120;
// position for the new node
nodeOffset = nodeutils?.calculateNewNodePosition(
nodeList, Size(width.toDouble(), height.toDouble()));
nodeOutput =
Offset(nodeOffset!.dx + (width + 5), nodeOffset.dy + (height / 2 + 2));
nodeInput = Offset(nodeOffset.dx + 5, nodeOffset.dy + (height / 2 + 2));
nodeList.add(Node(
id: id!,
inputId: "${id}-input",
outputId: "${id}-output",
label: defaultLabel,
offset: nodeOffset,
nodeInput: nodeInput,
nodeOutput: nodeOutput,
height: height,
width: width,
type: type!,
isdone: isDone));
emit(NodeUpdatedState(nodeList));
idController.clear();
labelController.clear();
heightController.clear();
widthController.clear();
}
void updateOffset(String? nodeId, Offset? newOffset) {
for (var node in nodeList) {
final newNodeOutputOffset = Offset(newOffset!.dx + (node.width! + 5),
newOffset.dy + (node.height! / 2 + 2));
final newNodeInputOffset =
Offset(newOffset.dx + 5, newOffset.dy + (node.height! / 2 + 2));
if (node.id == nodeId) {
final updatedNode = node.copyWith(
offset: newOffset,
nodeOutput: newNodeOutputOffset,
nodeInput: newNodeInputOffset);
nodeList[nodeList.indexOf(node)] = updatedNode;
}
}
emit(NodeUpdatedState(nodeList));
}
void addConnection(String? outputId, Offset? outputOffset, String? inputId,
Offset? inputOffset) {
List<NodeConnection> newLinkedNodes = [...linkedNodes];
newLinkedNodes.add(NodeConnection(
inputId: inputId ?? const Uuid().v4(),
outputId: outputId,
inputOffset: inputOffset,
outputOffset: outputOffset,
connectionOn: true,
));
linkedNodes = [...newLinkedNodes];
emit(NodeConnectionUpdatedState(newLinkedNodes));
notifyListeners();
}
void updateConnection(String? outputId, Offset? outputOffset, String? inputId,
Offset? inputOffset) {
for (var connection in linkedNodes) {
if (connection.outputId == outputId &&
connection.outputOffset != outputOffset) {
connection.outputOffset = outputOffset;
}
if (connection.inputId == inputId &&
connection.inputOffset != inputOffset) {
connection.inputOffset = inputOffset;
}
}
emit(NodeConnectionUpdatedState(linkedNodes));
notifyListeners();
}
i already faced a similar problem in the past, and it was fixed with deferpointer but now I tried that, I tried moving the custompaint outside the stack, I tried to use flow instead of stack and a multitude of solutions but the problem still remains, here is a video showing the problem. https://vimeo.com/989014777?share=copy sorry about the bad quality.
I have fixed it before it was even published haha the solution is simple, here is the main fix "inputOffset = _globalToLocal(details.globalPosition);" " Offset _globalToLocal(Offset globalPosition) { RenderBox renderBox = context.findRenderObject() as RenderBox; return renderBox.globalToLocal(globalPosition); }" not sure why this works but details.localPosition doesn't but if it works it works haha, please mark this as answered, have a great day my friends.