From d86448f9658a5cfbaa150c2a176313fa7ece8ef1 Mon Sep 17 00:00:00 2001 From: Tong Mu Date: Mon, 17 Jun 2024 12:28:08 -0700 Subject: [PATCH] Reland: [CupertinoActionSheet] Add sliding tap gesture (#150219) Relands https://github.com/flutter/flutter/pull/149471. As explained in https://github.com/flutter/flutter/pull/150147, the original PR was reverted in an attempt to fix the strange golden file failure that occurred in other PRs. The following code is changed: * The two tests that contain golden tests are changed so that, they used to slide from button 1 to button 2, now they slide from button 2 to button 1. This is to ensure the golden file record are updated. --- .../flutter/lib/src/cupertino/dialog.dart | 352 +++++++++++++++- .../test/cupertino/action_sheet_test.dart | 397 +++++++++++++++++- 2 files changed, 725 insertions(+), 24 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/dialog.dart b/packages/flutter/lib/src/cupertino/dialog.dart index 0459c4e8e767..c482b5fee499 100644 --- a/packages/flutter/lib/src/cupertino/dialog.dart +++ b/packages/flutter/lib/src/cupertino/dialog.dart @@ -6,6 +6,7 @@ import 'dart:math' as math; import 'dart:ui' show ImageFilter; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; @@ -445,6 +446,280 @@ class CupertinoPopupSurface extends StatelessWidget { } } +typedef _HitTester = HitTestResult Function(Offset location); + +// Recognizes taps with possible sliding during the tap. +// +// This recognizer only tracks one pointer at a time (called the primary +// pointer), and other pointers added while the primary pointer is alive are +// ignored and can not be used by other gestures either. After the primary +// pointer ends, the pointer added next becomes the new primary pointer (which +// starts a new gesture sequence). +// +// This recognizer only allows [kPrimaryMouseButton]. +class _SlidingTapGestureRecognizer extends VerticalDragGestureRecognizer { + _SlidingTapGestureRecognizer({ + super.debugOwner, + }) { + dragStartBehavior = DragStartBehavior.down; + } + + /// Called whenever the primary pointer moves regardless of whether drag has + /// started. + /// + /// The parameter is the global position of the primary pointer. + /// + /// This is similar to `onUpdate`, but allows the caller to track the primary + /// pointer's location before the drag starts, which is useful to enhance + /// responsiveness. + ValueSetter? onResponsiveUpdate; + + /// Called whenever the primary pointer is lifted regardless of whether drag + /// has started. + /// + /// The parameter is the global position of the primary pointer. + /// + /// This is similar to `onEnd`, but allows know the primary pointer's final + /// location even if the drag never started, which is useful to enhance + /// responsiveness. + ValueSetter? onResponsiveEnd; + + int? _primaryPointer; + + @override + void addAllowedPointer(PointerDownEvent event) { + _primaryPointer ??= event.pointer; + super.addAllowedPointer(event); + } + + @override + void rejectGesture(int pointer) { + if (pointer == _primaryPointer) { + _primaryPointer = null; + } + super.rejectGesture(pointer); + } + + @override + void handleEvent(PointerEvent event) { + if (event.pointer == _primaryPointer) { + if (event is PointerMoveEvent) { + onResponsiveUpdate?.call(event.position); + } + // If this gesture has a competing gesture (such as scrolling), and the + // pointer has not moved far enough to get this panning accepted, a + // pointer up event should still be considered as an accepted tap up. + // Manually accept this gesture here, which triggers onDragEnd. + if (event is PointerUpEvent) { + resolve(GestureDisposition.accepted); + stopTrackingPointer(_primaryPointer!); + onResponsiveEnd?.call(event.position); + } else { + super.handleEvent(event); + } + if (event is PointerUpEvent || event is PointerCancelEvent) { + _primaryPointer = null; + } + } + } + + @override + String get debugDescription => 'tap slide'; +} + +// A region (typically a button) that can receive entering, exiting, and +// updating events of a "sliding tap" gesture. +// +// Some Cupertino widgets, such as action sheets or dialogs, allow the user to +// select buttons using "sliding taps", where the user can drag around after +// pressing on the screen, and whichever button the drag ends in is selected. +// +// This class is used to define the regions that sliding taps recognize. This +// class must be provided to a `MetaData` widget as `data`, and is typically +// implemented by a widget state class. When an eligible dragging gesture +// enters, leaves, or ends this `MetaData` widget, corresponding methods of this +// class will be called. +// +// Multiple `_ActionSheetSlideTarget`s might be nested. +// `_TargetSelectionGestureRecognizer` uses a simple algorithm that only +// compares if the inner-most slide target has changed (which suffices our use +// case). Semantically, this means that all outer targets will be treated as +// identical to the inner-most one, i.e. when the pointer enters or leaves a +// slide target, the corresponding method will be called on all targets that +// nest it. +abstract class _ActionSheetSlideTarget { + // A pointer has entered this region. + // + // This includes: + // + // * The pointer has moved into this region from outside. + // * The point has contacted the screen in this region. In this case, this + // method is called as soon as the pointer down event occurs regardless of + // whether the gesture wins the arena immediately. + void didEnter(); + + // A pointer has exited this region. + // + // This includes: + // * The pointer has moved out of this region. + // * The pointer is no longer in contact with the screen. + // * The pointer is canceled. + // * The gesture loses the arena. + // * The gesture ends. In this case, this method is called immediately + // before [didConfirm]. + void didLeave(); + + // The drag gesture is completed in this region. + // + // This method is called immediately after a [didLeave]. + void didConfirm(); +} + +// Recognizes sliding taps and thereupon interacts with +// `_ActionSheetSlideTarget`s. +class _TargetSelectionGestureRecognizer extends GestureRecognizer { + _TargetSelectionGestureRecognizer({super.debugOwner, required this.hitTest}) + : _slidingTap = _SlidingTapGestureRecognizer(debugOwner: debugOwner) { + _slidingTap + ..onDown = _onDown + ..onResponsiveUpdate = _onUpdate + ..onResponsiveEnd = _onEnd + ..onCancel = _onCancel; + } + + final _HitTester hitTest; + + final List<_ActionSheetSlideTarget> _currentTargets = <_ActionSheetSlideTarget>[]; + final _SlidingTapGestureRecognizer _slidingTap; + + @override + void acceptGesture(int pointer) { + _slidingTap.acceptGesture(pointer); + } + + @override + void rejectGesture(int pointer) { + _slidingTap.rejectGesture(pointer); + } + + @override + void addPointer(PointerDownEvent event) { + _slidingTap.addPointer(event); + } + + @override + void addPointerPanZoom(PointerPanZoomStartEvent event) { + _slidingTap.addPointerPanZoom(event); + } + + @override + void dispose() { + _slidingTap.dispose(); + super.dispose(); + } + + // Collect the `_ActionSheetSlideTarget`s that are currently hit by the + // pointer, check whether the current target have changed, and invoke their + // methods if necessary. + void _updateDrag(Offset pointerPosition) { + final HitTestResult result = hitTest(pointerPosition); + + // A slide target might nest other targets, therefore multiple targets might + // be found. + final List<_ActionSheetSlideTarget> foundTargets = <_ActionSheetSlideTarget>[]; + for (final HitTestEntry entry in result.path) { + if (entry.target case final RenderMetaData target) { + if (target.metaData is _ActionSheetSlideTarget) { + foundTargets.add(target.metaData as _ActionSheetSlideTarget); + } + } + } + + // Compare whether the active target has changed by simply comparing the + // first (inner-most) avatar of the nest, ignoring the cases where + // _currentTargets intersect with foundTargets (see _ActionSheetSlideTarget's + // document for more explanation). + if (_currentTargets.firstOrNull != foundTargets.firstOrNull) { + for (final _ActionSheetSlideTarget target in _currentTargets) { + target.didLeave(); + } + _currentTargets + ..clear() + ..addAll(foundTargets); + for (final _ActionSheetSlideTarget target in _currentTargets) { + target.didEnter(); + } + } + } + + void _onDown(DragDownDetails details) { + _updateDrag(details.globalPosition); + } + + void _onUpdate(Offset globalPosition) { + _updateDrag(globalPosition); + } + + void _onEnd(Offset globalPosition) { + _updateDrag(globalPosition); + for (final _ActionSheetSlideTarget target in _currentTargets) { + target.didConfirm(); + } + _currentTargets.clear(); + } + + void _onCancel() { + for (final _ActionSheetSlideTarget target in _currentTargets) { + target.didLeave(); + } + _currentTargets.clear(); + } + + @override + String get debugDescription => 'target selection'; +} + +// The gesture detector used by action sheets. +// +// This gesture detector only recognizes one gesture, +// `_TargetSelectionGestureRecognizer`. +// +// This widget's child might contain another VerticalDragGestureRecognizer if +// the actions section or the content section scrolls. Conveniently, Flutter's +// gesture algorithm makes the inner gesture take priority. +class _ActionSheetGestureDetector extends StatelessWidget { + const _ActionSheetGestureDetector({ + this.child, + }); + + final Widget? child; + + HitTestResult _hitTest(BuildContext context, Offset globalPosition) { + final int viewId = View.of(context).viewId; + final HitTestResult result = HitTestResult(); + WidgetsBinding.instance.hitTestInView(result, globalPosition, viewId); + return result; + } + + @override + Widget build(BuildContext context) { + final Map gestures = {}; + gestures[_TargetSelectionGestureRecognizer] = GestureRecognizerFactoryWithHandlers<_TargetSelectionGestureRecognizer>( + () => _TargetSelectionGestureRecognizer( + debugOwner: this, + hitTest: (Offset globalPosition) => _hitTest(context, globalPosition), + ), + (_TargetSelectionGestureRecognizer instance) {} + ); + + return RawGestureDetector( + excludeFromSemantics: true, + gestures: gestures, + child: child, + ); + } +} + /// An iOS-style action sheet. /// /// {@youtube 560 315 https://www.youtube.com/watch?v=U-ao8p4A82k} @@ -518,7 +793,7 @@ class CupertinoActionSheet extends StatefulWidget { /// The set of actions that are displayed for the user to select. /// - /// Typically this is a list of [CupertinoActionSheetAction] widgets. + /// This must be a list of [CupertinoActionSheetAction] widgets. final List? actions; /// A scroll controller that can be used to control the scrolling of the @@ -537,7 +812,7 @@ class CupertinoActionSheet extends StatefulWidget { /// The optional cancel button that is grouped separately from the other /// actions. /// - /// Typically this is an [CupertinoActionSheetAction] widget. + /// This must be a [CupertinoActionSheetAction] widget. final Widget? cancelButton; @override @@ -663,10 +938,16 @@ class _CupertinoActionSheetState extends State { ), child: SizedBox( width: actionSheetWidth - _kActionSheetEdgeHorizontalPadding * 2, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, + child: _ActionSheetGestureDetector( + child: Semantics( + explicitChildNodes: true, + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ), ), ), ), @@ -686,7 +967,7 @@ class _CupertinoActionSheetState extends State { /// /// * [CupertinoActionSheet], an alert that presents the user with a set of two or /// more choices related to the current context. -class CupertinoActionSheetAction extends StatelessWidget { +class CupertinoActionSheetAction extends StatefulWidget { /// Creates an action for an iOS-style action sheet. const CupertinoActionSheetAction({ super.key, @@ -696,7 +977,10 @@ class CupertinoActionSheetAction extends StatelessWidget { required this.child, }); - /// The callback that is called when the button is tapped. + /// The callback that is called when the button is selected. + /// + /// The button can be selected by either by tapping on this button or by + /// pressing elsewhere and sliding onto this button before releasing. final VoidCallback onPressed; /// Whether this action is the default choice in the action sheet. @@ -714,22 +998,42 @@ class CupertinoActionSheetAction extends StatelessWidget { /// Typically a [Text] widget. final Widget child; + @override + State createState() => _CupertinoActionSheetActionState(); +} + +class _CupertinoActionSheetActionState extends State + implements _ActionSheetSlideTarget { + // |_ActionSheetSlideTarget| + @override + void didEnter() {} + + // |_ActionSheetSlideTarget| + @override + void didLeave() {} + + // |_ActionSheetSlideTarget| + @override + void didConfirm() { + widget.onPressed(); + } + @override Widget build(BuildContext context) { TextStyle style = _kActionSheetActionStyle.copyWith( - color: isDestructiveAction + color: widget.isDestructiveAction ? CupertinoDynamicColor.resolve(CupertinoColors.systemRed, context) : CupertinoTheme.of(context).primaryColor, ); - if (isDefaultAction) { + if (widget.isDefaultAction) { style = style.copyWith(fontWeight: FontWeight.w600); } return MouseRegion( cursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, - child: GestureDetector( - onTap: onPressed, + child: MetaData( + metaData: this, behavior: HitTestBehavior.opaque, child: ConstrainedBox( constraints: const BoxConstraints( @@ -737,6 +1041,7 @@ class CupertinoActionSheetAction extends StatelessWidget { ), child: Semantics( button: true, + onTap: widget.onPressed, child: Padding( padding: const EdgeInsets.symmetric( vertical: 16.0, @@ -745,7 +1050,7 @@ class CupertinoActionSheetAction extends StatelessWidget { child: DefaultTextStyle( style: style, textAlign: TextAlign.center, - child: Center(child: child), + child: Center(child: widget.child), ), ), ), @@ -780,20 +1085,26 @@ class _ActionSheetButtonBackground extends StatefulWidget { _ActionSheetButtonBackgroundState createState() => _ActionSheetButtonBackgroundState(); } -class _ActionSheetButtonBackgroundState extends State<_ActionSheetButtonBackground> { +class _ActionSheetButtonBackgroundState extends State<_ActionSheetButtonBackground> implements _ActionSheetSlideTarget { bool isBeingPressed = false; - void _onTapDown(TapDownDetails event) { + // |_ActionSheetSlideTarget| + @override + void didEnter() { setState(() { isBeingPressed = true; }); widget.onPressStateChange?.call(true); } - void _onTapUp(TapUpDetails event) { + // |_ActionSheetSlideTarget| + @override + void didLeave() { setState(() { isBeingPressed = false; }); widget.onPressStateChange?.call(false); } - void _onTapCancel() { + // |_ActionSheetSlideTarget| + @override + void didConfirm() { setState(() { isBeingPressed = false; }); widget.onPressStateChange?.call(false); } @@ -812,11 +1123,8 @@ class _ActionSheetButtonBackgroundState extends State<_ActionSheetButtonBackgrou : CupertinoColors.secondarySystemGroupedBackground; borderRadius = const BorderRadius.all(Radius.circular(_kCornerRadius)); } - return GestureDetector( - excludeFromSemantics: true, - onTapDown: _onTapDown, - onTapUp: _onTapUp, - onTapCancel: _onTapCancel, + return MetaData( + metaData: this, child: Container( decoration: BoxDecoration( color: backgroundColor, diff --git a/packages/flutter/test/cupertino/action_sheet_test.dart b/packages/flutter/test/cupertino/action_sheet_test.dart index 90a285678ab6..0f272d862375 100644 --- a/packages/flutter/test/cupertino/action_sheet_test.dart +++ b/packages/flutter/test/cupertino/action_sheet_test.dart @@ -531,7 +531,7 @@ void main() { ); }); - testWidgets('Tap on button calls onPressed', (WidgetTester tester) async { + testWidgets('Taps on button calls onPressed', (WidgetTester tester) async { bool wasPressed = false; await tester.pumpWidget( createAppWithButtonThatLaunchesActionSheet( @@ -541,6 +541,7 @@ void main() { CupertinoActionSheetAction( child: const Text('One'), onPressed: () { + expect(wasPressed, false); wasPressed = true; Navigator.pop(context); }, @@ -568,7 +569,48 @@ void main() { expect(find.text('One'), findsNothing); }); - testWidgets('Tap at the padding of buttons calls onPressed', (WidgetTester tester) async { + testWidgets('Can tap after scrolling', (WidgetTester tester) async { + int? wasPressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder(builder: (BuildContext context) { + return CupertinoActionSheet( + actions: List.generate(20, (int i) => + CupertinoActionSheetAction( + onPressed: () { + expect(wasPressed, null); + wasPressed = i; + }, + child: Text('Button $i'), + ), + ), + ); + }), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('Button 19').hitTestable(), findsNothing); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Button 1'))); + await tester.pumpAndSettle(); + // The dragging gesture must be dispatched in at least two segments. + // The first movement starts the gesture without setting a delta. + await gesture.moveBy(const Offset(0, -20)); + await tester.pumpAndSettle(); + await gesture.moveBy(const Offset(0, -1000)); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.text('Button 19').hitTestable(), findsOne); + + await tester.tap(find.text('Button 19')); + await tester.pumpAndSettle(); + expect(wasPressed, 19); + }); + + testWidgets('Taps at the padding of buttons calls onPressed', (WidgetTester tester) async { // Ensures that the entire button responds to hit tests, not just the text // part. bool wasPressed = false; @@ -580,6 +622,7 @@ void main() { CupertinoActionSheetAction( child: const Text('One'), onPressed: () { + expect(wasPressed, false); wasPressed = true; Navigator.pop(context); }, @@ -609,6 +652,321 @@ void main() { expect(find.text('One'), findsNothing); }); + testWidgets('Taps on a button can be slided to other buttons', (WidgetTester tester) async { + int? pressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder(builder: (BuildContext context) { + return CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () { + expect(pressed, null); + pressed = 1; + Navigator.pop(context); + }, + ), + CupertinoActionSheetAction( + child: const Text('Two'), + onPressed: () { + expect(pressed, null); + pressed = 2; + Navigator.pop(context); + }, + ), + ], + ); + }), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(pressed, null); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Two'))); + await tester.pumpAndSettle(); + + await gesture.moveTo(tester.getCenter(find.text('One'))); + await tester.pumpAndSettle(); + await expectLater( + find.byType(CupertinoActionSheet), + matchesGoldenFile('cupertinoActionSheet.press-drag.png'), + ); + + await gesture.up(); + expect(pressed, 1); + await tester.pumpAndSettle(); + expect(find.text('One'), findsNothing); + }); + + testWidgets('Taps on the content can be slided to other buttons', (WidgetTester tester) async { + bool wasPressed = false; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder(builder: (BuildContext context) { + return CupertinoActionSheet( + title: const Text('The title'), + actions: [ + CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () { + }, + ), + ], + cancelButton: CupertinoActionSheetAction( + child: const Text('Cancel'), + onPressed: () { + expect(wasPressed, false); + wasPressed = true; + Navigator.pop(context); + }, + ), + ); + }), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(wasPressed, false); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('The title'))); + await tester.pumpAndSettle(); + + await gesture.moveTo(tester.getCenter(find.text('Cancel'))); + await tester.pumpAndSettle(); + await gesture.up(); + expect(wasPressed, true); + await tester.pumpAndSettle(); + expect(find.text('One'), findsNothing); + }); + + testWidgets('Taps on the barrier can not be slided to buttons', (WidgetTester tester) async { + bool wasPressed = false; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder(builder: (BuildContext context) { + return CupertinoActionSheet( + title: const Text('The title'), + cancelButton: CupertinoActionSheetAction( + child: const Text('Cancel'), + onPressed: () { + expect(wasPressed, false); + wasPressed = true; + Navigator.pop(context); + }, + ), + ); + }), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(wasPressed, false); + + // Press on the barrier. + final TestGesture gesture = await tester.startGesture(const Offset(100, 100)); + await tester.pumpAndSettle(); + + await gesture.moveTo(tester.getCenter(find.text('Cancel'))); + await tester.pumpAndSettle(); + await gesture.up(); + expect(wasPressed, false); + await tester.pumpAndSettle(); + expect(find.text('Cancel'), findsOne); + }); + + testWidgets('Sliding taps can still yield to scrolling after horizontal movement', (WidgetTester tester) async { + int? pressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder(builder: (BuildContext context) { + return CupertinoActionSheet( + message: Text('Long message' * 200), + actions: List.generate(10, (int i) => + CupertinoActionSheetAction( + onPressed: () { + expect(pressed, null); + pressed = i; + }, + child: Text('Button $i'), + ), + ), + ); + }), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + // Starts on a button. + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Button 0'))); + await tester.pumpAndSettle(); + // Move horizontally. + await gesture.moveBy(const Offset(-10, 2)); + await gesture.moveBy(const Offset(-100, 2)); + await tester.pumpAndSettle(); + // Scroll up. + await gesture.moveBy(const Offset(0, -40)); + await gesture.moveBy(const Offset(0, -1000)); + await tester.pumpAndSettle(); + // Stop scrolling. + await gesture.up(); + await tester.pumpAndSettle(); + // The actions section should have been scrolled up and Button 9 is visible. + await tester.tap(find.text('Button 9')); + expect(pressed, 9); + }); + + testWidgets('Sliding taps is responsive even before the drag starts', (WidgetTester tester) async { + int? pressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder(builder: (BuildContext context) { + return CupertinoActionSheet( + message: Text('Long message' * 200), + actions: List.generate(10, (int i) => + CupertinoActionSheetAction( + onPressed: () { + expect(pressed, null); + pressed = i; + }, + child: Text('Button $i'), + ), + ), + ); + }), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + // Find the location right within the upper edge of button 1. + final Offset start = tester.getTopLeft(find.text('Button 1')) + const Offset(30, -15); + // Verify that the start location is within button 1. + await tester.tapAt(start); + expect(pressed, 1); + pressed = null; + + final TestGesture gesture = await tester.startGesture(start); + await tester.pumpAndSettle(); + // Move slightly upwards without starting the drag + await gesture.moveBy(const Offset(0, -10)); + await tester.pumpAndSettle(); + // Stop scrolling. + await gesture.up(); + await tester.pumpAndSettle(); + expect(pressed, 0); + }); + + testWidgets('Sliding taps only recognizes the primary pointer', (WidgetTester tester) async { + int? pressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder(builder: (BuildContext context) { + return CupertinoActionSheet( + title: const Text('The title'), + actions: List.generate(8, (int i) => + CupertinoActionSheetAction( + onPressed: () { + expect(pressed, null); + pressed = i; + }, + child: Text('Button $i'), + ), + ), + ); + }), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + // Start gesture 1 at button 0 + final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.text('Button 0'))); + await gesture1.moveBy(const Offset(0, 20)); // Starts the gesture + await tester.pumpAndSettle(); + + // Start gesture 2 at button 1. + final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.text('Button 1'))); + await gesture2.moveBy(const Offset(0, 20)); // Starts the gesture + await tester.pumpAndSettle(); + + // Move gesture 1 to button 2 and release. + await gesture1.moveTo(tester.getCenter(find.text('Button 2'))); + await tester.pumpAndSettle(); + await gesture1.up(); + await tester.pumpAndSettle(); + + expect(pressed, 2); + pressed = null; + + // Tap at button 3, which becomes the new primary pointer and is recognized. + await tester.tap(find.text('Button 3')); + await tester.pumpAndSettle(); + expect(pressed, 3); + pressed = null; + + // Move gesture 2 to button 4 and release. + await gesture2.moveTo(tester.getCenter(find.text('Button 4'))); + await tester.pumpAndSettle(); + await gesture2.up(); + await tester.pumpAndSettle(); + + // Non-primary pointers should not be recognized. + expect(pressed, null); + }); + + testWidgets('Non-primary pointers can trigger scroll', (WidgetTester tester) async { + int? pressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + Builder(builder: (BuildContext context) { + return CupertinoActionSheet( + actions: List.generate(12, (int i) => + CupertinoActionSheetAction( + onPressed: () { + expect(pressed, null); + pressed = i; + }, + child: Text('Button $i'), + ), + ), + ); + }), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + // Start gesture 1 at button 0 + final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.text('Button 0'))); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Button 11')).dy, greaterThan(400)); + + // Start gesture 2 at button 1 and scrolls. + final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.text('Button 1'))); + await gesture2.moveBy(const Offset(0, -20)); + await gesture2.moveBy(const Offset(0, -500)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Button 11')).dy, lessThan(400)); + + // Release gesture 1, which should not trigger any buttons. + await gesture1.up(); + await tester.pumpAndSettle(); + + expect(pressed, null); + }); + testWidgets('Action sheet width is correct when given infinite horizontal space', (WidgetTester tester) async { await tester.pumpWidget( createAppWithButtonThatLaunchesActionSheet( @@ -871,6 +1229,7 @@ void main() { cancelButton: CupertinoActionSheetAction( child: const Text('Cancel'), onPressed: () { + expect(wasPressed, false); wasPressed = true; Navigator.pop(context); }, @@ -934,6 +1293,40 @@ void main() { expect(tester.getBottomLeft(find.widgetWithText(CupertinoActionSheetAction, 'Two')).dy, 526.0); }); + testWidgets('Action buttons shows pressed color as soon as the pointer is down', (WidgetTester tester) async { + // Verifies that the the pressed color is not delayed for some milliseconds, + // a symptom if the color relies on a tap gesture timing out. + await tester.pumpWidget( + createAppWithButtonThatLaunchesActionSheet( + CupertinoActionSheet( + title: const Text('The title'), + actions: [ + CupertinoActionSheetAction( + child: const Text('One'), + onPressed: () { }, + ), + CupertinoActionSheetAction( + child: const Text('Two'), + onPressed: () { }, + ), + ], + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final TestGesture pointer = await tester.startGesture(tester.getCenter(find.text('Two'))); + // Just `pump`, not `pumpAndSettle`, as we want to verify the very next frame. + await tester.pump(); + await expectLater( + find.byType(CupertinoActionSheet), + matchesGoldenFile('cupertinoActionSheet.pressed.png'), + ); + await pointer.up(); + }); + testWidgets('Enter/exit animation is correct', (WidgetTester tester) async { await tester.pumpWidget( createAppWithButtonThatLaunchesActionSheet(