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