Search code examples
flutterdartcanvasoffsetflutter-custompainter

Flutter: customepaint and gesture out of sync when offscreen


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.


Solution

  • 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.