No hit testing for GestureDetector outside the Stack
- Dominant language
- Dart
- Stars
- 179k
- Forks
- 31.1k
- PR merge metrics
- PR metrics pending
Description
I tried to find answer on my question why the part of object which is wrapped by `GestureDetector` which is out of its parent `Stack` is excluded from hit testing?
Below is a sample where there is a chid widget which needs to be resized. There are resize handles on sides and corners of it. When I move mouse over the handler the cursor is appeared only on part which is inside the `Stack`. But I need that the whole circle be active. I tried to use `clipBehaviour` with `Clip.none` The question is why is it so? Can I read about this somewhere?
Here it a sampe
```dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// --- 0. Handle Type Enum ---
/// Defines the type of resize handle being interacted with.
enum HandleType {
topLeft,
topCenter,
topRight,
centerLeft,
centerRight,
bottomLeft,
bottomCenter,
bottomRight,
}
// --- 1. Resizable Data Model ---
/// Holds the current width, height, and position (x, y) of the resizable rect.
/// Notifies listeners when the size or position changes.
class ResizableData extends ChangeNotifier {
double _width;
double _height;
double _x;
double _y;
static const double minSize = 50.0;
static const double initialWidth = 100.0;
static const double initialHeight = 100.0;
static const double initialX = 30.0;
static const double initialY = 30.0;
// The fixed size of the parent "canvas" within which the rect resizes and moves.
static const double canvasWidth = 400.0;
static const double canvasHeight = 300.0;
ResizableData()
: _width = initialWidth,
_height = initialHeight,
_x = initialX,
_y = initialY {
// Ensure initial values are within valid bounds.
// Clamp width/height first to minSize and canvas max.
_width = _width.clamp(minSize, canvasWidth);
_height = _height.clamp(minSize, canvasHeight);
// Then clamp x/y to ensure the rectangle stays within the canvas.
_x = _x.clamp(0.0, canvasWidth - _width);
_y = _y.clamp(0.0, canvasHeight - _height);
}
double get width => _width;
double get height => _height;
double get x => _x;
double get y => _y;
/// Applies a delta to the rect's dimensions and position based on the handle type.
/// Handles clamping for minimum size and canvas boundaries.
void applyDelta(double dx, double dy, HandleType handleType) {
double newX = _x;
double newY = _y;
double newWidth = _width;
double newHeight = _height;
switch (handleType) {
case HandleType.topLeft:
newX += dx;
newY += dy;
newWidth -= dx;
newHeight -= dy;
break;
case HandleType.topCenter:
newY += dy;
newHeight -= dy;
break;
case HandleType.topRight:
newY += dy;
newWidth += dx;
newHeight -= dy;
break;
case HandleType.centerLeft:
newX += dx;
newWidth -= dx;
break;
case HandleType.centerRight:
newWidth += dx;
break;
case HandleType.bottomLeft:
newX += dx;
newWidth -= dx;
newHeight += dy;
break;
case HandleType.bottomCenter:
newHeight += dy;
break;
case HandleType.bottomRight:
newWidth += dx;
newHeight += dy;
break;
}
// --- Clamping Logic ---
// 1. Calculate effective width/height considering minSize and canvas limits.
// We need to consider the current x/y to ensure width/height don't push beyond canvas.
double clampedWidth = newWidth.clamp(minSize, canvasWidth - newX);
double clampedHeight = newHeight.clamp(minSize, canvasHeight - newY);
// If clamping for width/height occurred, it might affect calculated x/y.
// Example: If newWidth was too small, it's clamped to minSize.
// If it was a left-side drag, newX needs to be adjusted back by the amount of width clamping.
if (newWidth < minSize &&
(handleType == HandleType.topLeft ||
handleType == HandleType.centerLeft ||
handleType == HandleType.bottomLeft)) {
newX -= (minSize - newWidth); // Adjust X backward if width was artificially increased to minSize
}
if (newHeight < minSize &&
(handleType == HandleType.topLeft ||
handleType == HandleType.topCenter ||
handleType == HandleType.topRight)) {
newY -= (minSize - newHeight); // Adjust Y backward
}
newWidth = clampedWidth;
newHeight = clampedHeight;
// 2. Clamp x/y to ensure they stay within canvas boundaries.
double clampedX = newX.clamp(0.0, canvasWidth - newWidth);
double clampedY = newY.clamp(0.0, canvasHeight - newHeight);
// If x/y was clamped, width/height might need to be re-clamped to fit.
if (clampedX != newX &&
(handleType == HandleType.topLeft ||
handleType == HandleType.centerLeft ||
handleType == HandleType.bottomLeft)) {
newWidth = (newWidth + newX - clampedX).clamp(minSize, canvasWidth - clampedX);
} else if (clampedX != newX) {
newWidth = newWidth.clamp(minSize, canvasWidth - clampedX);
}
if (clampedY != newY &&
(handleType == HandleType.topLeft ||
handleType == HandleType.topCenter ||
handleType == HandleType.topRight)) {
newHeight = (newHeight + newY - clampedY).clamp(minSize, canvasHeight - clampedY);
} else if (clampedY != newY) {
newHeight = newHeight.clamp(minSize, canvasHeight - clampedY);
}
// Final re-clamp of width/height based on final position if needed
newWidth = newWidth.clamp(minSize, canvasWidth - clampedX);
newHeight = newHeight.clamp(minSize, canvasHeight - clampedY);
// Only update and notify if values have actually changed
if (_x != clampedX || _y != clampedY || _width != newWidth || _height != newHeight) {
_x = clampedX;
_y = clampedY;
_width = newWidth;
_height = newHeight;
notifyListeners();
}
}
}
// --- 2. Main Application Setup ---
void main() {
runApp(const ResizableApp());
}
class ResizableApp extends StatelessWidget {
const ResizableApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Resizable Object',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: ChangeNotifierProvider(
create: (context) => ResizableData(),
builder: (context, child) => const Scaffold(
appBar: PreferredSize(
preferredSize: Size.fromHeight(40.0),
child: MyAppBar(),
),
body: Center(
child: BiggerRect(),
),
),
),
);
}
}
class MyAppBar extends StatelessWidget implements PreferredSizeWidget {
const MyAppBar({super.key});
@override
Size get preferredSize => const Size.fromHeight(40.0);
@override
Widget build(BuildContext context) {
return AppBar(
title: const Text('Resizable Object', style: TextStyle(fontSize: 16)),
backgroundColor: Colors.blueGrey,
);
}
}
// --- 3. Bigger (Parent) Rect Widget ---
class BiggerRect extends StatelessWidget {
const BiggerRect({super.key});
@override
Widget build(BuildContext context) {
// BiggerRect acts as a fixed-size canvas for the ResizableRect.
return Container(
width: ResizableData.canvasWidth,
height: ResizableData.canvasHeight,
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.3),
border: Border.all(color: Colors.blue, width: 2),
),
child: Stack(
clipBehavior: Clip.none,
children: [
// Listen to ResizableData to get the current position (x, y)
// for positioning the ResizableRect within this BiggerRect's stack.
Consumer(
builder: (context, resizableData, child) {
return Positioned(
top: resizableData.y,
left: resizableData.x,
child: const ResizableRect(),
);
},
),
],
),
);
}
}
// --- 4. Resizable (Small) Rect Widget ---
class ResizableRect extends StatelessWidget {
// Use a unique key to prevent unnecessary rebuilds of the small rect itself
const ResizableRect({super.key});
// Size of the drag handle
static const double _handleSize = 20.0;
@override
Widget build(BuildContext context) {
// Watch the ResizableData to get the current size, causing this widget to rebuild
// when width/height changes. The x, y are handled by the parent Positioned.
final resizableData = context.watch();
final width = resizableData.width;
final height = resizableData.height;
// Helper to create a handle widget at a specific position with a given cursor.
Widget _buildHandle({
required HandleType type,
double? top,
double? left,
double? right,
double? bottom,
MouseCursor cursor = SystemMouseCursors.basic,
}) {
return Positioned(
top: top,
left: left,
right: right,
bottom: bottom,
child: DragHandle(
handleSize: _handleSize,
cursor: cursor,
onDrag: (details) {
// Read (not watch) the ResizableData for action, avoiding circular rebuilds
// if ResizableData itself were to trigger this widget.
context.read().applyDelta(details.delta.dx, details.delta.dy, type);
},
),
);
}
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.6),
border: Border.all(color: Colors.red, width: 1),
),
// The small rect has its own Stack for the content and the drag handles
child: Stack(
clipBehavior: Clip.none, // Allow handles to draw outside the bounds of this container
children: [
// Content of the small rect
const Center(
child: Text(
'Drag any handle',
style: TextStyle(color: Colors.white, fontSize: 12),
),
),
// --- Resize Handles ---
// Top-Left Corner Handle
_buildHandle(
type: HandleType.topLeft,
top: -_handleSize / 2, // Modified to lay over border
left: -_handleSize / 2, // Modified to lay over border
cursor: SystemMouseCursors.resizeUpLeft,
),
// Top-Center Side Handle
_buildHandle(
type: HandleType.topCenter,
top: -_handleSize / 2, // Modified to lay over border
left: width / 2 - _handleSize / 2,
cursor: SystemMouseCursors.resizeUpDown,
),
// Top-Right Corner Handle
_buildHandle(
type: HandleType.topRight,
top: -_handleSize / 2, // Modified to lay over border
right: -_handleSize / 2, // Modified to lay over border
cursor: SystemMouseCursors.resizeUpRight,
),
// Center-Left Side Handle
_buildHandle(
type: HandleType.centerLeft,
left: -_handleSize / 2, // Modified to lay over border
top: height / 2 - _handleSize / 2,
cursor: SystemMouseCursors.resizeLeftRight,
),
// Center-Right Side Handle
_buildHandle(
type: HandleType.centerRight,
right: -_handleSize / 2, // Modified to lay over border
top: height / 2 - _handleSize / 2,
cursor: SystemMouseCursors.resizeLeftRight,
),
// Bottom-Left Corner Handle
_buildHandle(
type: HandleType.bottomLeft,
bottom: -_handleSize / 2, // Modified to lay over border
left: -_handleSize / 2, // Modified to lay over border
cursor: SystemMouseCursors.resizeDownLeft,
),
// Bottom-Center Side Handle
_buildHandle(
type: HandleType.bottomCenter,
bottom: -_handleSize / 2, // Modified to lay over border
left: width / 2 - _handleSize / 2,
cursor: SystemMouseCursors.resizeUpDown,
),
// Bottom-Right Corner Handle
_buildHandle(
type: HandleType.bottomRight,
bottom: -_handleSize / 2, // Modified to lay over border
right: -_handleSize / 2, // Modified to lay over border
cursor: SystemMouseCursors.resizeDownRight,
),
],
),
);
}
}
// --- 5. Drag Handle Widget ---
class DragHandle extends StatelessWidget {
final double handleSize;
final ValueSetter onDrag;
final MouseCursor cursor;
const DragHandle({
super.key,
required this.handleSize,
required this.onDrag,
this.cursor = SystemMouseCursors.basic, // Default cursor
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanUpdate: onDrag,
child: MouseRegion(
cursor: cursor, // Apply the specific cursor for this handle
child: Container(
width: handleSize,
height: handleSize,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.black, width: 1),
// CHANGE: Make it circular
borderRadius: BorderRadius.circular(handleSize / 2),
),
),
),
);
}
}
```
_P.S. In my current project I created a workaround - increase the size of `Stack` on radius of circle handle and shift circles inside to make them all belonging to `Stack`. Is this the only way to fix the behavour?_
Contributor guide
Assessment
This issue has not been assessed yet.