diff --git a/packages/flutter/lib/src/cupertino/context_menu.dart b/packages/flutter/lib/src/cupertino/context_menu.dart index 1a98cf5bf4e4..da2aed2a092a 100644 --- a/packages/flutter/lib/src/cupertino/context_menu.dart +++ b/packages/flutter/lib/src/cupertino/context_menu.dart @@ -6,7 +6,7 @@ import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart' show kMinFlingVelocity; +import 'package:flutter/gestures.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart' show HapticFeedback; import 'package:flutter/widgets.dart'; @@ -480,6 +480,7 @@ class _CupertinoContextMenuState extends State with Ticker OverlayEntry? _lastOverlayEntry; _ContextMenuRoute? _route; final double _midpoint = CupertinoContextMenu.animationOpensAt / 2; + late final TapGestureRecognizer _tapGestureRecognizer; @override void initState() { @@ -490,13 +491,20 @@ class _CupertinoContextMenuState extends State with Ticker upperBound: CupertinoContextMenu.animationOpensAt, ); _openController.addStatusListener(_onDecoyAnimationStatusChange); + _tapGestureRecognizer = TapGestureRecognizer() + ..onTapCancel = _onTapCancel + ..onTapDown = _onTapDown + ..onTapUp = _onTapUp + ..onTap = _onTap; } void _listenerCallback() { if (_openController.status != AnimationStatus.reverse && - _openController.value >= _midpoint && - widget.enableHapticFeedback) { - HapticFeedback.heavyImpact(); + _openController.value >= _midpoint) { + if (widget.enableHapticFeedback) { + HapticFeedback.heavyImpact(); + } + _tapGestureRecognizer.resolve(GestureDisposition.accepted); _openController.removeListener(_listenerCallback); } } @@ -663,11 +671,8 @@ class _CupertinoContextMenuState extends State with Ticker Widget build(BuildContext context) { return MouseRegion( cursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, - child: GestureDetector( - onTapCancel: _onTapCancel, - onTapDown: _onTapDown, - onTapUp: _onTapUp, - onTap: _onTap, + child: Listener( + onPointerDown: _tapGestureRecognizer.addPointer, child: TickerMode( enabled: !_childHidden, child: Visibility.maintain( diff --git a/packages/flutter/test/cupertino/context_menu_test.dart b/packages/flutter/test/cupertino/context_menu_test.dart index 146b15192b6e..06817cbb3b67 100644 --- a/packages/flutter/test/cupertino/context_menu_test.dart +++ b/packages/flutter/test/cupertino/context_menu_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:clock/clock.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; @@ -810,4 +811,74 @@ void main() { expect(right.dx, lessThan(left.dx)); }); }); + + testWidgets('Conflicting gesture detectors', (WidgetTester tester) async { + int? onPointerDownTime; + int? onPointerUpTime; + bool insideTapTriggered = false; + // The required duration of the route to be pushed in is [500, 900]ms. + // 500ms is calculated from kPressTimeout+_previewLongPressTimeout/2. + // 900ms is calculated from kPressTimeout+_previewLongPressTimeout. + const Duration pressDuration = Duration(milliseconds: 501); + + int now() => clock.now().millisecondsSinceEpoch; + + await tester.pumpWidget(Listener( + onPointerDown: (PointerDownEvent event) => onPointerDownTime = now(), + onPointerUp: (PointerUpEvent event) => onPointerUpTime = now(), + child: CupertinoApp( + home: Align( + child: CupertinoContextMenu( + actions: const [ + CupertinoContextMenuAction( + child: Text('CupertinoContextMenuAction'), + ), + ], + child: GestureDetector( + onTap: () => insideTapTriggered = true, + child: Container( + width: 200, + height: 200, + key: const Key('container'), + color: const Color(0xFF00FF00), + ), + ), + ), + ), + ), + )); + + // Start a press on the child. + final TestGesture gesture = await tester.createGesture(); + await gesture.down(tester.getCenter(find.byKey(const Key('container')))); + // Simulate the actual situation: + // the user keeps pressing and requesting frames. + // If there is only one frame, + // the animation is mutant and cannot drive the value of the animation controller. + for (int i = 0; i < 100; i++) { + await tester.pump(pressDuration ~/ 100); + } + await gesture.up(); + // Await pushing route. + await tester.pumpAndSettle(); + + // Judge whether _ContextMenuRouteStatic present on the screen. + final Finder routeStatic = find.byWidgetPredicate( + (Widget w) => '${w.runtimeType}' == '_ContextMenuRouteStatic', + ); + + // The insideTap and the route should not be triggered at the same time. + if (insideTapTriggered) { + // Calculate the actual duration. + final int actualDuration = onPointerUpTime! - onPointerDownTime!; + + expect(routeStatic, findsNothing, + reason: 'When actualDuration($actualDuration) is in the range of 500ms~900ms, ' + 'which means the route is pushed, ' + 'but insideTap should not be triggered at the same time.'); + } else { + // The route should be pushed when the insideTap is not triggered. + expect(routeStatic, findsOneWidget); + } + }); }