Revert "Revert "[CupertinoActionSheet] Add sliding tap gesture" (#150147)"

This reverts commit 317ad4ab7c.
This commit is contained in:
Tong Mu 2024-06-13 10:29:25 -07:00 committed by GitHub
parent 39edf2a31f
commit b29535785d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 724 additions and 24 deletions

View File

@ -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<Offset>? 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<Offset>? 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<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};
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<Widget>? 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<CupertinoActionSheet> {
),
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> {
///
/// * [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<CupertinoActionSheetAction> createState() => _CupertinoActionSheetActionState();
}
class _CupertinoActionSheetActionState extends State<CupertinoActionSheetAction>
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,

View File

@ -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<Widget>.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: <Widget>[
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('One')));
await tester.pumpAndSettle();
await gesture.moveTo(tester.getCenter(find.text('Two')));
await tester.pumpAndSettle();
await expectLater(
find.byType(CupertinoActionSheet),
matchesGoldenFile('cupertinoActionSheet.press-drag.png'),
);
await gesture.up();
expect(pressed, 2);
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: <Widget>[
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<Widget>.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<Widget>.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<Widget>.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<Widget>.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,39 @@ 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: <Widget>[
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('One')));
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(