Make pressing and moving on CupertinoButton closer to native behavior. (#161731)

Fixes: #91581 

This PR adds an `onTapMove` event to `TapGestureRecognizer`, and then
`CupertinoButton` uses this event to implement the behavior of the
native iOS `UIButton` (as shown in the issue). It is worth noting that
this PR slightly changes the way `CupertinoButton`'s `onPressed` is
triggered. Specifically, it changes from being triggered by
`TapGestureRecognizer`'s `onTap` to checking if the event position is
still above the button in `TapGestureRecognizer`'s `onTapUp`.
Additionally, it removes the previous behavior where the gesture was
canceled if moved beyond `kScaleSlop` (18 logical pixels). Overall,
previously, `onPressed` could not be triggered if the button was pressed
and then moved more than 18 pixels. This PR adjusts it so that
`onPressed` cannot be triggered if the button is pressed and then moved
outside the button.

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
yim 2025-03-01 07:57:35 +08:00 committed by GitHub
parent 7f23dc7a0d
commit 246d6c6834
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 254 additions and 11 deletions

View File

@ -6,6 +6,7 @@
library; library;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/semantics.dart'; import 'package:flutter/semantics.dart';
import 'package:flutter/widgets.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. /// enable a button, set [onPressed] or [onLongPress] to a non-null value.
bool get enabled => onPressed != null || onLongPress != null; 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 @override
State<CupertinoButton> createState() => _CupertinoButtonState(); State<CupertinoButton> createState() => _CupertinoButtonState();
@ -329,6 +342,11 @@ class _CupertinoButtonState extends State<CupertinoButton> with SingleTickerProv
_buttonHeldDown = false; _buttonHeldDown = false;
_animate(); _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() { void _handleTapCancel() {
@ -338,6 +356,18 @@ class _CupertinoButtonState extends State<CupertinoButton> 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? _]) { void _handleTap([Intent? _]) {
if (widget.onPressed != null) { if (widget.onPressed != null) {
widget.onPressed!(); widget.onPressed!();
@ -429,7 +459,7 @@ class _CupertinoButtonState extends State<CupertinoButton> with SingleTickerProv
size: size:
textStyle.fontSize != null ? textStyle.fontSize! * 1.2 : kCupertinoButtonDefaultIconSize, textStyle.fontSize != null ? textStyle.fontSize! * 1.2 : kCupertinoButtonDefaultIconSize,
); );
final DeviceGestureSettings? gestureSettings = MediaQuery.maybeGestureSettingsOf(context);
return MouseRegion( return MouseRegion(
cursor: enabled && kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, cursor: enabled && kIsWeb ? SystemMouseCursors.click : MouseCursor.defer,
child: FocusableActionDetector( child: FocusableActionDetector(
@ -439,13 +469,29 @@ class _CupertinoButtonState extends State<CupertinoButton> with SingleTickerProv
onFocusChange: widget.onFocusChange, onFocusChange: widget.onFocusChange,
onShowFocusHighlight: _onShowFocusHighlight, onShowFocusHighlight: _onShowFocusHighlight,
enabled: enabled, enabled: enabled,
child: GestureDetector( child: RawGestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTapDown: enabled ? _handleTapDown : null, gestures: <Type, GestureRecognizerFactory>{
onTapUp: enabled ? _handleTapUp : null, TapGestureRecognizer: GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
onTapCancel: enabled ? _handleTapCancel : null, () => TapGestureRecognizer(postAcceptSlopTolerance: null),
onTap: widget.onPressed, (TapGestureRecognizer instance) {
onLongPress: widget.onLongPress, 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(),
(LongPressGestureRecognizer instance) {
instance.onLongPress = widget.onLongPress;
instance.gestureSettings = gestureSettings;
},
),
},
child: Semantics( child: Semantics(
button: true, button: true,
child: ConstrainedBox( child: ConstrainedBox(

View File

@ -84,3 +84,11 @@ const Map<CupertinoButtonSize, double> kCupertinoButtonMinSize = <CupertinoButto
CupertinoButtonSize.medium: 32, CupertinoButtonSize.medium: 32,
CupertinoButtonSize.large: 44, CupertinoButtonSize.large: 44,
}; };
/// 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.
/// This variable is effective on mobile platforms. For desktop platforms, a distance of 0 is used.
///
/// This value was obtained through actual testing on an iOS 18.1 simulator.
const double kCupertinoButtonTapMoveSlop = 70.0;

View File

@ -78,6 +78,35 @@ class TapUpDetails {
final PointerDeviceKind kind; final PointerDeviceKind kind;
} }
/// Details object for callbacks that use [GestureTapMoveCallback].
///
/// See also:
///
/// * [GestureDetector.onTapMove], which receives this information.
/// * [TapGestureRecognizer], which passes this information to one of its callbacks.
class TapMoveDetails {
/// Creates a [TapMoveDetails] data object.
TapMoveDetails({
required this.kind,
this.globalPosition = Offset.zero,
this.delta = Offset.zero,
Offset? localPosition,
}) : localPosition = localPosition ?? globalPosition;
/// The global position at which the pointer contacted the screen.
final Offset globalPosition;
/// The local position at which the pointer contacted the screen.
final Offset localPosition;
/// The kind of the device that initiated the event.
final PointerDeviceKind kind;
/// The amount the pointer has moved in the coordinate space of the
/// event receiver since the previous update.
final Offset delta;
}
/// {@template flutter.gestures.tap.GestureTapUpCallback} /// {@template flutter.gestures.tap.GestureTapUpCallback}
/// Signature for when a pointer that will trigger a tap has stopped contacting /// Signature for when a pointer that will trigger a tap has stopped contacting
/// the screen. /// the screen.
@ -100,6 +129,16 @@ typedef GestureTapUpCallback = void Function(TapUpDetails details);
/// * [TapGestureRecognizer], which uses this signature in one of its callbacks. /// * [TapGestureRecognizer], which uses this signature in one of its callbacks.
typedef GestureTapCallback = void Function(); typedef GestureTapCallback = void Function();
/// Signature for when a pointer that triggered a tap has moved.
///
/// The position at which the pointer moved is available in the `details`.
///
/// See also:
///
/// * [GestureDetector.onTapMove], which matches this signature.
/// * [TapGestureRecognizer], which uses this signature in one of its callbacks.
typedef GestureTapMoveCallback = void Function(TapMoveDetails details);
/// Signature for when the pointer that previously triggered a /// Signature for when the pointer that previously triggered a
/// [GestureTapDownCallback] will not end up causing a tap. /// [GestureTapDownCallback] will not end up causing a tap.
/// ///
@ -143,8 +182,13 @@ abstract class BaseTapGestureRecognizer extends PrimaryPointerGestureRecognizer
/// Creates a tap gesture recognizer. /// Creates a tap gesture recognizer.
/// ///
/// {@macro flutter.gestures.GestureRecognizer.supportedDevices} /// {@macro flutter.gestures.GestureRecognizer.supportedDevices}
BaseTapGestureRecognizer({super.debugOwner, super.supportedDevices, super.allowedButtonsFilter}) BaseTapGestureRecognizer({
: super(deadline: kPressTimeout); super.debugOwner,
super.supportedDevices,
super.allowedButtonsFilter,
super.preAcceptSlopTolerance,
super.postAcceptSlopTolerance,
}) : super(deadline: kPressTimeout);
bool _sentTapDown = false; bool _sentTapDown = false;
bool _wonArenaForPrimaryPointer = false; bool _wonArenaForPrimaryPointer = false;
@ -178,6 +222,15 @@ abstract class BaseTapGestureRecognizer extends PrimaryPointerGestureRecognizer
@protected @protected
void handleTapUp({required PointerDownEvent down, required PointerUpEvent up}); void handleTapUp({required PointerDownEvent down, required PointerUpEvent up});
/// A pointer that triggered a tap has moved.
///
/// This triggers on the move event if the recognizer has recognized the tap gesture.
///
/// The parameter `move` is the move event of the primary pointer that started
/// the tap sequence.
@protected
void handleTapMove({required PointerMoveEvent move}) {}
/// A pointer that previously triggered [handleTapDown] will not end up /// A pointer that previously triggered [handleTapDown] will not end up
/// causing a tap. /// causing a tap.
/// ///
@ -247,6 +300,8 @@ abstract class BaseTapGestureRecognizer extends PrimaryPointerGestureRecognizer
} else if (event.buttons != _down!.buttons) { } else if (event.buttons != _down!.buttons) {
resolve(GestureDisposition.rejected); resolve(GestureDisposition.rejected);
stopTrackingPointer(primaryPointer!); stopTrackingPointer(primaryPointer!);
} else if (event is PointerMoveEvent) {
_checkMove(event);
} }
} }
@ -312,6 +367,11 @@ abstract class BaseTapGestureRecognizer extends PrimaryPointerGestureRecognizer
handleTapCancel(down: _down!, cancel: event, reason: note); handleTapCancel(down: _down!, cancel: event, reason: note);
} }
void _checkMove(PointerMoveEvent event) {
assert(event.pointer == _down!.pointer);
handleTapMove(move: event);
}
void _reset() { void _reset() {
_sentTapDown = false; _sentTapDown = false;
_wonArenaForPrimaryPointer = false; _wonArenaForPrimaryPointer = false;
@ -381,7 +441,13 @@ class TapGestureRecognizer extends BaseTapGestureRecognizer {
/// Creates a tap gesture recognizer. /// Creates a tap gesture recognizer.
/// ///
/// {@macro flutter.gestures.GestureRecognizer.supportedDevices} /// {@macro flutter.gestures.GestureRecognizer.supportedDevices}
TapGestureRecognizer({super.debugOwner, super.supportedDevices, super.allowedButtonsFilter}); TapGestureRecognizer({
super.debugOwner,
super.supportedDevices,
super.allowedButtonsFilter,
super.preAcceptSlopTolerance,
super.postAcceptSlopTolerance,
});
/// {@template flutter.gestures.tap.TapGestureRecognizer.onTapDown} /// {@template flutter.gestures.tap.TapGestureRecognizer.onTapDown}
/// A pointer has contacted the screen at a particular location with a primary /// A pointer has contacted the screen at a particular location with a primary
@ -438,6 +504,20 @@ class TapGestureRecognizer extends BaseTapGestureRecognizer {
/// * [GestureDetector.onTap], which exposes this callback. /// * [GestureDetector.onTap], which exposes this callback.
GestureTapCallback? onTap; GestureTapCallback? onTap;
/// A pointer that triggered a tap has moved.
///
/// This callback is triggered after the tap gesture has been recognized and the pointer starts to move.
///
/// If the pointer moves beyond the `postAcceptSlopTolerance` distance, the tap will be canceled.
/// To make `onTapMove` more useful, consider setting `postAcceptSlopTolerance` to a larger value,
/// or to `null` for no limit on movement.
///
/// See also:
///
/// * [kPrimaryButton], the button this callback responds to.
/// * [GestureDetector.onTapMove], which exposes this callback.
GestureTapMoveCallback? onTapMove;
/// {@template flutter.gestures.tap.TapGestureRecognizer.onTapCancel} /// {@template flutter.gestures.tap.TapGestureRecognizer.onTapCancel}
/// A pointer that previously triggered [onTapDown] will not end up causing /// A pointer that previously triggered [onTapDown] will not end up causing
/// a tap. /// a tap.
@ -591,7 +671,11 @@ class TapGestureRecognizer extends BaseTapGestureRecognizer {
bool isPointerAllowed(PointerDownEvent event) { bool isPointerAllowed(PointerDownEvent event) {
switch (event.buttons) { switch (event.buttons) {
case kPrimaryButton: case kPrimaryButton:
if (onTapDown == null && onTap == null && onTapUp == null && onTapCancel == null) { if (onTapDown == null &&
onTap == null &&
onTapUp == null &&
onTapCancel == null &&
onTapMove == null) {
return false; return false;
} }
case kSecondaryButton: case kSecondaryButton:
@ -667,6 +751,20 @@ class TapGestureRecognizer extends BaseTapGestureRecognizer {
} }
} }
@protected
@override
void handleTapMove({required PointerMoveEvent move}) {
if (onTapMove != null && move.buttons == kPrimaryButton) {
final TapMoveDetails details = TapMoveDetails(
globalPosition: move.position,
localPosition: move.localPosition,
kind: getKindForPointer(move.pointer),
delta: move.delta,
);
invokeCallback<void>('onTapMove', () => onTapMove!(details));
}
}
@protected @protected
@override @override
void handleTapCancel({ void handleTapCancel({

View File

@ -240,6 +240,7 @@ class GestureDetector extends StatelessWidget {
this.onTapDown, this.onTapDown,
this.onTapUp, this.onTapUp,
this.onTap, this.onTap,
this.onTapMove,
this.onTapCancel, this.onTapCancel,
this.onSecondaryTap, this.onSecondaryTap,
this.onSecondaryTapDown, this.onSecondaryTapDown,
@ -373,6 +374,15 @@ class GestureDetector extends StatelessWidget {
/// regarding the pointer position. /// regarding the pointer position.
final GestureTapCallback? onTap; 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 /// The pointer that previously triggered [onTapDown] will not end up causing
/// a tap. /// a tap.
/// ///

View File

@ -885,6 +885,62 @@ void main() {
await tester.pump(); await tester.pump();
expect(value, isTrue); 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}) { Widget boilerplate({required Widget child}) {

View File

@ -1110,4 +1110,29 @@ void main() {
expect(didTap, isFalse); 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));
});
} }