From 2c96e63b362567100b827e4f39c634ed4ee8b121 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 26 Aug 2026 10:07:51 +0200 Subject: [PATCH] fix: Require touch slop before DragCallbacks accepts a drag Closes #4016 Claude-Session: https://claude.ai/code/session_01EJANnBFMk4Cfh3JRbUboQZ --- doc/flame/inputs/drag_events.md | 5 + .../events/multi_drag_scale_recognizer.dart | 17 ++- .../component_mixins/drag_callbacks_test.dart | 140 +++++++++++++++++- 3 files changed, 155 insertions(+), 7 deletions(-) diff --git a/doc/flame/inputs/drag_events.md b/doc/flame/inputs/drag_events.md index cbda45dd0d4..aed24edefc1 100644 --- a/doc/flame/inputs/drag_events.md +++ b/doc/flame/inputs/drag_events.md @@ -58,6 +58,11 @@ local coordinate system. Any component that receives `onDragStart` will later be receiving `onDragUpdate` and `onDragEnd` events as well. +A drag only starts once the pointer has moved further than the platform's touch slop from the +point where it went down, so a tap with a slightly wobbling finger is still delivered as a tap and +not as a drag. When the drag starts, the movement accumulated before that point is delivered in the +first `onDragUpdate`. + ### onDragUpdate diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index ffaeafd486d..51ea1528cf5 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -480,10 +480,17 @@ class _DragPointerState { if (!_resolved) { _pendingDelta += delta; if (!recognizer.hasScale) { - // Drag-only mode: accept on any movement. If accepted synchronously, - // _accepted fires the initial update with _pendingDelta; no extra - // update fires here because we are still in the if(!_resolved) branch. - _arenaEntry?.resolve(GestureDisposition.accepted); + // Drag-only mode: accept once the accumulated movement exceeds the + // hit slop, matching ImmediateMultiDragGestureRecognizer. Some + // platforms emit zero-delta move events during a tap, so accepting on + // any move would steal the gesture from tap recognizers. If accepted + // synchronously, _accepted fires the initial update with + // _pendingDelta; no extra update fires here because we are still in + // the if(!_resolved) branch. + final hitSlop = computeHitSlop(kind, recognizer.gestureSettings); + if (_pendingDelta.distance > hitSlop) { + _arenaEntry?.resolve(GestureDisposition.accepted); + } } else { final distance = (currentPosition - initialPosition).distance; if (distance > computePanSlop(kind, recognizer.gestureSettings)) { @@ -511,12 +518,10 @@ class _DragPointerState { if (_drag != null) { _drag!.end(DragEndDetails(velocity: velocityTracker.getVelocity())); } - _resolved = true; } void _cancel(PointerCancelEvent event) { _drag?.cancel(); - _resolved = true; } void _accepted(Drag? Function() starter) { diff --git a/packages/flame/test/events/component_mixins/drag_callbacks_test.dart b/packages/flame/test/events/component_mixins/drag_callbacks_test.dart index 8dc2b4d3790..7c6cc41e0c0 100644 --- a/packages/flame/test/events/component_mixins/drag_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/drag_callbacks_test.dart @@ -333,7 +333,7 @@ void main() { // cancelled drag final gesture = await tester.startGesture(const Offset(50, 50)); - await gesture.moveBy(const Offset(10, 10)); + await gesture.moveBy(const Offset(20, 20)); await gesture.cancel(); await tester.pump(const Duration(seconds: 1)); expect(nDragStartCalled, 2); @@ -343,6 +343,127 @@ void main() { }, ); + testWidgets( + 'tap is not cancelled when a DragCallbacks component is mounted first', + (tester) async { + var nDragStartCalled = 0; + final tapComponent = _TapCounterComponent( + position: Vector2(20, 20), + size: Vector2(100, 100), + ); + final game = FlameGame( + children: [ + DragWithCallbacksComponent( + position: Vector2(200, 200), + size: Vector2(100, 100), + onDragStart: (e) => nDragStartCalled++, + ), + tapComponent, + ], + ); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 10)); + + await tester.tapAt(const Offset(50, 50)); + await tester.pump(const Duration(seconds: 1)); + + expect(nDragStartCalled, 0); + expect(tapComponent.nTapDown, 1); + expect(tapComponent.nTapUp, 1); + expect(tapComponent.nTapCancel, 0); + }, + ); + + testWidgets( + 'zero-delta pointer moves do not start a drag or cancel a tap', + (tester) async { + var nDragStartCalled = 0; + final tapComponent = _TapCounterComponent( + position: Vector2(20, 20), + size: Vector2(100, 100), + ); + final game = FlameGame( + children: [ + DragWithCallbacksComponent( + position: Vector2(200, 200), + size: Vector2(100, 100), + onDragStart: (e) => nDragStartCalled++, + ), + tapComponent, + ], + ); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 10)); + + final gesture = await tester.startGesture(const Offset(50, 50)); + await gesture.moveBy(Offset.zero); + await gesture.moveBy(Offset.zero); + await gesture.moveBy(Offset.zero); + await gesture.up(); + await tester.pump(const Duration(seconds: 1)); + + expect(nDragStartCalled, 0); + expect(tapComponent.nTapDown, 1); + expect(tapComponent.nTapUp, 1); + expect(tapComponent.nTapCancel, 0); + }, + ); + + testWidgets( + 'pointer moves below the touch slop do not start a drag', + (tester) async { + var nDragStartCalled = 0; + var nDragUpdateCalled = 0; + var nDragEndCalled = 0; + final tapComponent = _TapCounterComponent( + position: Vector2(20, 20), + size: Vector2(100, 100), + ); + final game = FlameGame( + children: [ + DragWithCallbacksComponent( + position: Vector2(20, 20), + size: Vector2(100, 100), + onDragStart: (e) => nDragStartCalled++, + onDragUpdate: (e) => nDragUpdateCalled++, + onDragEnd: (e) => nDragEndCalled++, + ), + tapComponent, + ], + ); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 10)); + + // Movement below the touch slop is a tap, not a drag. + var gesture = await tester.startGesture(const Offset(50, 50)); + await gesture.moveBy(const Offset(5, 5)); + await gesture.moveBy(const Offset(5, 5)); + await gesture.up(); + await tester.pump(const Duration(seconds: 1)); + expect(nDragStartCalled, 0); + expect(nDragUpdateCalled, 0); + expect(nDragEndCalled, 0); + expect(tapComponent.nTapUp, 1); + expect(tapComponent.nTapCancel, 0); + + // Once the accumulated movement exceeds the touch slop the drag + // starts, and the pending movement is delivered as one update. + gesture = await tester.startGesture(const Offset(50, 50)); + await gesture.moveBy(const Offset(5, 5)); + await gesture.moveBy(const Offset(20, 20)); + await gesture.up(); + await tester.pump(const Duration(seconds: 1)); + expect(nDragStartCalled, 1); + expect(nDragUpdateCalled, 1); + expect(nDragEndCalled, 1); + expect(tapComponent.nTapUp, 1); + expect(tapComponent.nTapCancel, 1); + }, + ); + testWidgets( 'drag event does not affect more than one component', (tester) async { @@ -506,3 +627,20 @@ void main() { }, ); } + +class _TapCounterComponent extends PositionComponent with TapCallbacks { + _TapCounterComponent({super.position, super.size}); + + int nTapDown = 0; + int nTapUp = 0; + int nTapCancel = 0; + + @override + void onTapDown(TapDownEvent event) => nTapDown++; + + @override + void onTapUp(TapUpEvent event) => nTapUp++; + + @override + void onTapCancel(TapCancelEvent event) => nTapCancel++; +}