diff --git a/packages/flutter/lib/src/cupertino/button.dart b/packages/flutter/lib/src/cupertino/button.dart index 48385c39c3..6e09a8dd26 100644 --- a/packages/flutter/lib/src/cupertino/button.dart +++ b/packages/flutter/lib/src/cupertino/button.dart @@ -6,6 +6,7 @@ library; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter/widgets.dart'; @@ -263,6 +264,18 @@ class CupertinoButton extends StatefulWidget { /// enable a button, set [onPressed] or [onLongPress] to a non-null value. bool get enabled => onPressed != null || onLongPress != null; + /// The distance a button needs to be moved after being pressed for its opacity to change. + /// + /// The opacity changes when the position moved is this distance away from the button. + static double tapMoveSlop() { + return switch (defaultTargetPlatform) { + TargetPlatform.iOS || + TargetPlatform.android || + TargetPlatform.fuchsia => kCupertinoButtonTapMoveSlop, + TargetPlatform.macOS || TargetPlatform.linux || TargetPlatform.windows => 0.0, + }; + } + @override State createState() => _CupertinoButtonState(); @@ -329,6 +342,11 @@ class _CupertinoButtonState extends State with SingleTickerProv _buttonHeldDown = false; _animate(); } + final RenderBox renderObject = context.findRenderObject()! as RenderBox; + final Offset localPosition = renderObject.globalToLocal(event.globalPosition); + if (renderObject.paintBounds.inflate(CupertinoButton.tapMoveSlop()).contains(localPosition)) { + _handleTap(); + } } void _handleTapCancel() { @@ -338,6 +356,18 @@ class _CupertinoButtonState extends State with SingleTickerProv } } + void _handTapMove(TapMoveDetails event) { + final RenderBox renderObject = context.findRenderObject()! as RenderBox; + final Offset localPosition = renderObject.globalToLocal(event.globalPosition); + final bool buttonShouldHeldDown = renderObject.paintBounds + .inflate(CupertinoButton.tapMoveSlop()) + .contains(localPosition); + if (buttonShouldHeldDown != _buttonHeldDown) { + _buttonHeldDown = buttonShouldHeldDown; + _animate(); + } + } + void _handleTap([Intent? _]) { if (widget.onPressed != null) { widget.onPressed!(); @@ -429,7 +459,7 @@ class _CupertinoButtonState extends State with SingleTickerProv size: textStyle.fontSize != null ? textStyle.fontSize! * 1.2 : kCupertinoButtonDefaultIconSize, ); - + final DeviceGestureSettings? gestureSettings = MediaQuery.maybeGestureSettingsOf(context); return MouseRegion( cursor: enabled && kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, child: FocusableActionDetector( @@ -439,13 +469,29 @@ class _CupertinoButtonState extends State with SingleTickerProv onFocusChange: widget.onFocusChange, onShowFocusHighlight: _onShowFocusHighlight, enabled: enabled, - child: GestureDetector( + child: RawGestureDetector( behavior: HitTestBehavior.opaque, - onTapDown: enabled ? _handleTapDown : null, - onTapUp: enabled ? _handleTapUp : null, - onTapCancel: enabled ? _handleTapCancel : null, - onTap: widget.onPressed, - onLongPress: widget.onLongPress, + gestures: { + TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(postAcceptSlopTolerance: null), + (TapGestureRecognizer instance) { + instance.onTapDown = enabled ? _handleTapDown : null; + instance.onTapUp = enabled ? _handleTapUp : null; + instance.onTapCancel = enabled ? _handleTapCancel : null; + instance.onTapMove = enabled ? _handTapMove : null; + instance.gestureSettings = gestureSettings; + }, + ), + if (widget.onLongPress != null) + LongPressGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer(), + (LongPressGestureRecognizer instance) { + instance.onLongPress = widget.onLongPress; + instance.gestureSettings = gestureSettings; + }, + ), + }, child: Semantics( button: true, child: ConstrainedBox( diff --git a/packages/flutter/lib/src/cupertino/constants.dart b/packages/flutter/lib/src/cupertino/constants.dart index f209681408..7b171d2ddb 100644 --- a/packages/flutter/lib/src/cupertino/constants.dart +++ b/packages/flutter/lib/src/cupertino/constants.dart @@ -84,3 +84,11 @@ const Map kCupertinoButtonMinSize = ('onTapMove', () => onTapMove!(details)); + } + } + @protected @override void handleTapCancel({ diff --git a/packages/flutter/lib/src/widgets/gesture_detector.dart b/packages/flutter/lib/src/widgets/gesture_detector.dart index 1f076b7d5a..c9b0020e9f 100644 --- a/packages/flutter/lib/src/widgets/gesture_detector.dart +++ b/packages/flutter/lib/src/widgets/gesture_detector.dart @@ -240,6 +240,7 @@ class GestureDetector extends StatelessWidget { this.onTapDown, this.onTapUp, this.onTap, + this.onTapMove, this.onTapCancel, this.onSecondaryTap, this.onSecondaryTapDown, @@ -373,6 +374,15 @@ class GestureDetector extends StatelessWidget { /// regarding the pointer position. final GestureTapCallback? onTap; + /// A pointer that triggered a tap has moved. + /// + /// This triggers when the pointer moves after the tap gesture has been recognized. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureTapMoveCallback? onTapMove; + /// The pointer that previously triggered [onTapDown] will not end up causing /// a tap. /// diff --git a/packages/flutter/test/cupertino/button_test.dart b/packages/flutter/test/cupertino/button_test.dart index db7ac35ee0..5be1c794cb 100644 --- a/packages/flutter/test/cupertino/button_test.dart +++ b/packages/flutter/test/cupertino/button_test.dart @@ -885,6 +885,62 @@ void main() { await tester.pump(); expect(value, isTrue); }); + + testWidgets('Press and move on button and animation works', (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate(child: CupertinoButton(onPressed: () {}, child: const Text('Tap me'))), + ); + final TestGesture gesture = await tester.startGesture( + tester.getTopLeft(find.byType(CupertinoButton)), + ); + addTearDown(gesture.removePointer); + // Check opacity. + final FadeTransition opacity = tester.widget( + find.descendant(of: find.byType(CupertinoButton), matching: find.byType(FadeTransition)), + ); + await tester.pumpAndSettle(); + expect(opacity.opacity.value, 0.4); + final double moveDistance = CupertinoButton.tapMoveSlop(); + await gesture.moveBy(Offset(0, -moveDistance + 1)); + await tester.pumpAndSettle(); + expect(opacity.opacity.value, 0.4); + await gesture.moveBy(const Offset(0, -2)); + await tester.pumpAndSettle(); + expect(opacity.opacity.value, 1.0); + await gesture.moveBy(const Offset(0, 1)); + await tester.pumpAndSettle(); + expect(opacity.opacity.value, 0.4); + }, variant: TargetPlatformVariant.all()); + + testWidgets('onPressed trigger takes into account MoveSlop.', (WidgetTester tester) async { + bool value = false; + await tester.pumpWidget( + boilerplate( + child: CupertinoButton( + onPressed: () { + value = true; + }, + child: const Text('Tap me'), + ), + ), + ); + TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(CupertinoButton))); + await gesture.moveTo( + tester.getBottomRight(find.byType(CupertinoButton)) + + Offset(0, CupertinoButton.tapMoveSlop()), + ); + await gesture.up(); + expect(value, isFalse); + + gesture = await tester.startGesture(tester.getTopLeft(find.byType(CupertinoButton))); + await gesture.moveTo( + tester.getBottomRight(find.byType(CupertinoButton)) + + Offset(0, CupertinoButton.tapMoveSlop()), + ); + await gesture.moveBy(const Offset(0, -1)); + await gesture.up(); + expect(value, isTrue); + }); } Widget boilerplate({required Widget child}) { diff --git a/packages/flutter/test/gestures/tap_test.dart b/packages/flutter/test/gestures/tap_test.dart index 8925712d10..82f918ac97 100644 --- a/packages/flutter/test/gestures/tap_test.dart +++ b/packages/flutter/test/gestures/tap_test.dart @@ -1110,4 +1110,29 @@ void main() { expect(didTap, isFalse); }); + + testGesture('onTapMove works', (GestureTester tester) { + TapMoveDetails? tapMoveDetails; + final TapGestureRecognizer tap = TapGestureRecognizer(postAcceptSlopTolerance: null) + ..onTapMove = (TapMoveDetails detail) { + tapMoveDetails = detail; + }; + addTearDown(tap.dispose); + + final TestPointer pointer1 = TestPointer(); + final PointerDownEvent down = pointer1.down(Offset.zero); + tap.addPointer(down); + tester.closeArena(1); + tester.route(down); + tester.route(pointer1.move(const Offset(50.0, 0))); + expect(tapMoveDetails, isNotNull); + expect(tapMoveDetails!.globalPosition, const Offset(50.0, 0)); + expect(tapMoveDetails!.delta, const Offset(50.0, 0)); + tapMoveDetails = null; + + tester.route(pointer1.move(const Offset(60.0, 10))); + expect(tapMoveDetails, isNotNull); + expect(tapMoveDetails!.globalPosition, const Offset(60.0, 10)); + expect(tapMoveDetails!.delta, const Offset(10.0, 10)); + }); }