flutterdartcanvasscreengesturedetector

Gesture detector stops working when I drag object off scree (infinite canvas)


I am developing a mind map app, it is supposed to have an infinite canvas that allows you to pan and zoom in and out, when I add my node(test container) to the screen, i can move the node/container freely, but if I move it outside the canvas area (off screen) and then pan the canvas to it, the gesture detector of that node stops working, and it becomes static.
here is the Node code :

class MyNode extends StatefulWidget {
  final int? index;
  const MyNode({super.key, this.index});

  @override
  State<MyNode> createState() => _MyNodeState();
}

class _MyNodeState extends State<MyNode> {
  Offset _widgetOffset = Offset.zero;
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<NodeCubit, NodeState>(
      builder: (context, state) {
        if (state is NodeUpdatedState) {
          // list of nodes
          final nodeList = state.nodes;
          // node based on it's index
          final node = nodeList[widget.index!];
          return GestureDetector(
            behavior: HitTestBehavior.opaque,
            onPanUpdate: (details) {
              setState(() {
                // Update _widgetOffset with current drag position
                _widgetOffset = Offset(
                  _widgetOffset.dx + details.delta.dx,
                  _widgetOffset.dy + details.delta.dy,
                );
                //update node offset
                context.read<NodeCubit>().updateOffset(node.id, _widgetOffset);
              });
            },
            child: Transform.translate(
              offset: _widgetOffset,
              child: Container(
                height: node.height?.toDouble(),
                width: node.width?.toDouble(),
                decoration: BoxDecoration(
                  color: Colors.black87.withAlpha(200),
                  boxShadow: [BoxShadow(color: Colors.grey.withOpacity(0.1))],
                  border: Border.all(
                    color: Colors.white.withOpacity(0.1),
                    strokeAlign: BorderSide.strokeAlignOutside,
                    width: 1.25,
                  ),
                  borderRadius: BorderRadius.circular(6),
                ),
                child: const Align(
                  alignment: AlignmentDirectional.center,
                  // child: Text(
                  //   "${context.read<NodeCubit>().nodeList[index].label}",
                  //   style: const TextStyle(color: Colors.white),
                  // ),
                ),
              ),
            ),
          );
        } else {
          return const Text('Something went wrong...');
        }
      },
    );
  }
}

and this is the canvas code:

class MyMindMap extends StatefulWidget {
  const MyMindMap({super.key});

  @override
  MyMindMapWidgetState createState() => MyMindMapWidgetState();
}

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;
  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;
    });
  }

  @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 Scaffold(
      extendBody: true,
      extendBodyBehindAppBar: true,
      backgroundColor: const Color.fromARGB(255, 23, 23, 20),
      body: GestureDetector(
        onDoubleTap: _resetOffsetAndScale,
        onScaleStart: _handleScaleStart,
        onScaleUpdate: _handleScaleUpdate,
        child: CustomPaint(
          painter: !_staticBackground
              ? DynamicBackground(_offset)
              : StaticBackground(),
          child: Stack(
            clipBehavior: Clip.hardEdge,
            children: [
              // Nodes are wrapped with Transform.translate
              Transform(
                transform: Matrix4.diagonal3Values(_scale, _scale, 1.0)
                  ..translate(_offset.dx, _offset.dy),
                alignment: Alignment.center,
                child: Stack(
                  clipBehavior: Clip.hardEdge,
                  children: [
                    for (int index = 0;
                        index < context.read<NodeCubit>().nodeList.length;
                        index++)
                      MyNode(
                        index: index,
                      ),
                  ],
                ),
              ),
              // Toolbar and Add button remain outside of Transform.translate
              ToolBar(context),
              AddButton(
                nodeTap: () {
                  context.read<NodeCubit>().addNode(
                        const Uuid().v4(),
                        null,
                        nodeOffset,
                        null,
                        null,
                        context.read<NodeCubit>().nodeTypes.first,
                        false,
                      );
                  setState(() {
                    print(context.read<NodeCubit>().nodeList);
                  });
                },
              ),
            ],
          ),
        ),
      ),
    );
  }

I tried to use positioned instead of Transform but it just makes the problem worse, I also tried to add ignore Pointer but it didn't solve my problem.
sorry if this is a dumb question, I am still learning flutter.


Solution

  • Finally, I was able to find the answer, first of all, thanks to psking for his help, the interactive viewer solved half of the problem, but the other solution is using the Defer_pointer package, here is how I modified the code.

    1- I added the DeferredPointerHandler to the parent stack:

    class MyMindMapWidgetState extends State<MyMindMap>
        with WidgetsBindingObserver, AutomaticKeepAliveClientMixin {
      @override
      bool get wantKeepAlive => true;
    
      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;
      final bool _staticBackground = false;
    
      void _handleScaleStart(ScaleStartDetails details) {
        setState(() {
          _initialFocalPoint = details.localFocalPoint;
          _offsetOnScaleStart = _offset;
        });
      }
    
      @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();
      }
    
      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;
        });
      }
    
      @override
      Widget build(BuildContext context) {
        super.build(context);
        fullScreenMode();
        return Scaffold(
          extendBody: true,
          extendBodyBehindAppBar: true,
          backgroundColor: const Color.fromARGB(255, 23, 23, 20),
          body: GestureDetector(
            onDoubleTap: _resetOffsetAndScale,
            child: InteractiveViewer(
              panEnabled: true, // Enables panning
              scaleEnabled: true, // Enables scaling
              onInteractionUpdate: _handleScaleUpdate,
              onInteractionStart: _handleScaleStart,
              // onInteractionEnd: (details) {
              //   setState(() {
              //     _resetOffsetAndScale();
              //   });
              // },
              child: CustomPaint(
                painter: !_staticBackground
                    ? DynamicBackground(_offset)
                    : StaticBackground(),
                child: BlocBuilder<NodeCubit, NodeState>(
                  // Move BlocBuilder here
                  builder: (context, state) {
                    // Widget tree for nodes
                    return Stack(
                      clipBehavior: Clip.none,
                      children: [
                        // Nodes are wrapped with Transform.translate
                        Transform(
                          transform: Matrix4.diagonal3Values(_scale, _scale, 1.0)
                            ..translate(_offset.dx, _offset.dy),
                          alignment: Alignment.center,
                          child: DeferredPointerHandler(
                            child: Stack(
                              children: [
                                for (int index = 0;
                                    index <
                                        context.read<NodeCubit>().nodeList.length;
                                    index++)
                                  Center(child: MyNode(index: index))...etc
    

    2- I added the deferPointer above the node Gesture Detector:

    import 'package:defer_pointer/defer_pointer.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; // import 'package:idealink_models/ui_models/exports.dart'; import 'package:idealink/cubit/node/node_cubit.dart';
    
    class MyNode extends StatefulWidget {   final int? index;   const MyNode({super.key, this.index});
    
      @override   State<MyNode> createState() => _MyNodeState(); }
    
    class _MyNodeState extends State<MyNode> with AutomaticKeepAliveClientMixin {   @override   bool get wantKeepAlive
    => true;
    
      // final GlobalKey _widgetKey = GlobalKey();   Offset _widgetOffset
    = Offset.zero;   @override   Widget build(BuildContext context) {
        super.build(context);
        return BlocBuilder<NodeCubit, NodeState>(
          builder: (context, state) {
            if (state is NodeUpdatedState) {
              // list of nodes
              final nodeList = state.nodes;
              // node based on it's index
              final node = nodeList[widget.index!];
              return Transform.translate(
                offset: _widgetOffset,
                child: DeferPointer(
                  // key: _widgetKey,
                  child: GestureDetector(
                    behavior: HitTestBehavior.opaque,
                    onPanUpdate: (details) {
                      setState(() {
                        // Update _widgetOffset with current drag position
                        _widgetOffset = Offset(
                          _widgetOffset.dx + details.delta.dx,
                          _widgetOffset.dy + details.delta.dy,
                        );
                        //update node offset
                        context
                            .read<NodeCubit>()
                            .updateOffset(node.id, _widgetOffset);
                      });
                    },
                    child: Container(...etc