From c57f99e419ca0897971c8764bb4f5e83d26ee92b Mon Sep 17 00:00:00 2001 From: Tong Mu Date: Tue, 17 Sep 2024 13:16:18 -0700 Subject: [PATCH] [CupertinoAlertDialog] Add tap-slide gesture (#154853) This PR adds "sliding tap" to `CupertinoAlertDialog` and fixes https://github.com/flutter/flutter/issues/19786. Much of the needed infrastructure has been implemented in https://github.com/flutter/flutter/pull/150219, but this time with a new challenge to support disabled buttons, i.e. the button should not show tap highlight when pressed (https://github.com/flutter/flutter/issues/107371). * Why? Because whether a button is disabled is assigned to `CupertinoDialogAction`, while the background is rendered by a private class that wraps the action widget and built by the dialog body. We need a way to pass the boolean "enabled" from the child to the parent when the action is pressed. After much experimentation, I think the best way is to propagate this boolean using the custom gesture callback. * An alternative way is to make the wrapper widget use an inherited widget, which allows the child `CupertinoDialogAction` to place a `ValueGetter getEnabled` to the parent as soon as it's mounted. However, this is pretty ugly... This PR also fixes https://github.com/flutter/flutter/issues/107371, i.e. disabled `CupertinoDialogAction` no longer triggers the pressing highlight. However, while legacy buttons (custom button classes that are implemented by `GestureDetector.onTap`) still functions (their `onPressed` continues to work), disabled legacy buttons will still show pressing highlight, and there's no plan (actually, no way) to fix it. All tests related to sliding taps in `CupertinoActionSheet` has been copied to `CupertinoAlertDialog`, with additional tests for disabled buttons. --- .../flutter/lib/src/cupertino/dialog.dart | 164 +++-- .../test/cupertino/action_sheet_test.dart | 4 +- .../flutter/test/cupertino/dialog_test.dart | 560 +++++++++++++++++- 3 files changed, 674 insertions(+), 54 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/dialog.dart b/packages/flutter/lib/src/cupertino/dialog.dart index e3e754ba1e..56c0749386 100644 --- a/packages/flutter/lib/src/cupertino/dialog.dart +++ b/packages/flutter/lib/src/cupertino/dialog.dart @@ -462,14 +462,16 @@ class _CupertinoAlertDialogState extends State { width: isInAccessibilityMode ? _kAccessibilityCupertinoDialogWidth : _kCupertinoDialogWidth, - child: CupertinoPopupSurface( - isSurfacePainted: false, - child: Semantics( - namesRoute: true, - scopesRoute: true, - explicitChildNodes: true, - label: localizations.alertDialogLabel, - child: _buildBody(context), + child: _ActionSheetGestureDetector( + child: CupertinoPopupSurface( + isSurfacePainted: false, + child: Semantics( + namesRoute: true, + scopesRoute: true, + explicitChildNodes: true, + label: localizations.alertDialogLabel, + child: _buildBody(context), + ), ), ), ), @@ -666,10 +668,10 @@ class _SlidingTapGestureRecognizer extends VerticalDragGestureRecognizer { // Multiple `_SlideTarget`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. +// case). Semantically, this means that all outer targets will be treated as +// having the identical area as 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 _SlideTarget { // A pointer has entered this region. // @@ -682,7 +684,10 @@ abstract class _SlideTarget { // // The `fromPointerDown` should be true if this callback is triggered by a // PointerDownEvent, i.e. the second case from the list above. - void didEnter({required bool fromPointerDown}); + // + // The return value of this method is used as the `innerEnabled` for the next + // target, while `innerEnabled` of the innermost target is true. + bool didEnter({required bool fromPointerDown, required bool innerEnabled}); // A pointer has exited this region. // @@ -703,6 +708,10 @@ abstract class _SlideTarget { // Recognizes sliding taps and thereupon interacts with // `_SlideTarget`s. +// +// TODO(dkwingsmt): It should recompute hit testing when the app is updated, +// or better, share code with `MouseTracker`. +// https://github.com/flutter/flutter/issues/155266 class _TargetSelectionGestureRecognizer extends GestureRecognizer { _TargetSelectionGestureRecognizer({super.debugOwner, required this.hitTest}) : _slidingTap = _SlidingTapGestureRecognizer(debugOwner: debugOwner) { @@ -775,8 +784,12 @@ class _TargetSelectionGestureRecognizer extends GestureRecognizer { _currentTargets ..clear() ..addAll(foundTargets); + bool enabled = true; for (final _SlideTarget target in _currentTargets) { - target.didEnter(fromPointerDown: fromPointerDown); + enabled = target.didEnter( + fromPointerDown: fromPointerDown, + innerEnabled: enabled, + ); } } } @@ -1234,7 +1247,9 @@ class _CupertinoActionSheetActionState extends State implements _SlideTarget { // |_SlideTarget| @override - void didEnter({required bool fromPointerDown}) {} + bool didEnter({required bool fromPointerDown, required bool innerEnabled}) { + return innerEnabled; + } // |_SlideTarget| @override @@ -1377,11 +1392,15 @@ class _ActionSheetButtonBackgroundState extends State<_ActionSheetButtonBackgrou // |_SlideTarget| @override - void didEnter({required bool fromPointerDown}) { + bool didEnter({required bool fromPointerDown, required bool innerEnabled}) { + // Action sheet doesn't support disabled buttons, therefore `innerEnabled` + // is always true. + assert(innerEnabled); widget.onPressStateChange?.call(true); if (!fromPointerDown) { _emitVibration(); } + return innerEnabled; } // |_SlideTarget| @@ -1418,7 +1437,7 @@ class _ActionSheetButtonBackgroundState extends State<_ActionSheetButtonBackgrou borderRadius: borderRadius, ), child: widget.child, - ) + ), ); } } @@ -1843,7 +1862,7 @@ class _CupertinoAlertActionSection extends StatelessWidget { // Renders the background of a button (both the pressed background and the idle // background) and reports its state to the parent with `onPressStateChange`. -class _AlertDialogButtonBackground extends StatelessWidget { +class _AlertDialogButtonBackground extends StatefulWidget { const _AlertDialogButtonBackground({ required this.idleColor, required this.pressedColor, @@ -1868,37 +1887,58 @@ class _AlertDialogButtonBackground extends StatelessWidget { /// Typically a [Text] widget. final Widget child; - void onTapDown(TapDownDetails details) { - onPressStateChange?.call(true); + @override + _AlertDialogButtonBackgroundState createState() => _AlertDialogButtonBackgroundState(); +} + +class _AlertDialogButtonBackgroundState extends State<_AlertDialogButtonBackground> + implements _SlideTarget { + void _emitVibration(){ + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.android: + HapticFeedback.selectionClick(); + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + break; + } } - void onTapUp(TapUpDetails details) { - onPressStateChange?.call(false); + // |_SlideTarget| + @override + bool didEnter({required bool fromPointerDown, required bool innerEnabled}) { + widget.onPressStateChange?.call(innerEnabled); + if (innerEnabled && !fromPointerDown) { + _emitVibration(); + } + return innerEnabled; } - void onTapCancel() { - onPressStateChange?.call(false); + // |_SlideTarget| + @override + void didLeave() { + widget.onPressStateChange?.call(false); + } + + // |_SlideTarget| + @override + void didConfirm() { + widget.onPressStateChange?.call(false); } @override Widget build(BuildContext context) { - final Color backgroundColor = pressed ? pressedColor : idleColor; - return MergeSemantics( - // TODO(mattcarroll): Button press dynamics need overhaul for iOS: - // https://github.com/flutter/flutter/issues/19786 - child: GestureDetector( - excludeFromSemantics: true, - behavior: HitTestBehavior.opaque, - onTapDown: onTapDown, - onTapUp: onTapUp, - // TODO(mattcarroll): Cancel is currently triggered when user moves - // past slop instead of off button: https://github.com/flutter/flutter/issues/19783 - onTapCancel: onTapCancel, + final Color backgroundColor = widget.pressed ? widget.pressedColor : widget.idleColor; + return MetaData( + metaData: this, + child: MergeSemantics( child: Container( decoration: BoxDecoration( color: CupertinoDynamicColor.resolve(backgroundColor, context), ), - child: child, + child: widget.child, ), ), ); @@ -1911,7 +1951,7 @@ class _AlertDialogButtonBackground extends StatelessWidget { /// /// * [CupertinoAlertDialog], a dialog that informs the user about situations /// that require acknowledgment. -class CupertinoDialogAction extends StatelessWidget { +class CupertinoDialogAction extends StatefulWidget { /// Creates an action for an iOS-style dialog. const CupertinoDialogAction({ super.key, @@ -1958,10 +1998,31 @@ class CupertinoDialogAction extends StatelessWidget { /// Typically a [Text] widget. final Widget child; - /// Whether the button is enabled or disabled. Buttons are disabled by - /// default. To enable a button, set its [onPressed] property to a non-null - /// value. - bool get enabled => onPressed != null; + @override + State createState() => _CupertinoDialogActionState(); +} + +class _CupertinoDialogActionState extends State + implements _SlideTarget { + + // The button is enabled when it has [onPressed]. + bool get enabled => widget.onPressed != null; + + // |_SlideTarget| + @override + bool didEnter({required bool fromPointerDown, required bool innerEnabled}) { + return enabled; + } + + // |_SlideTarget| + @override + void didLeave() {} + + // |_SlideTarget| + @override + void didConfirm() { + widget.onPressed?.call(); + } // Dialog action content shrinks to fit, up to a certain point, and if it still // cannot fit at the minimum size, the text content is ellipsized. @@ -1991,7 +2052,7 @@ class CupertinoDialogAction extends StatelessWidget { ), child: Semantics( button: true, - onTap: onPressed, + onTap: widget.onPressed, child: DefaultTextStyle( style: textStyle, textAlign: TextAlign.center, @@ -2022,12 +2083,12 @@ class CupertinoDialogAction extends StatelessWidget { Widget build(BuildContext context) { TextStyle style = _kCupertinoDialogActionStyle.copyWith( color: CupertinoDynamicColor.resolve( - isDestructiveAction ? CupertinoColors.systemRed : CupertinoTheme.of(context).primaryColor, + widget.isDestructiveAction ? CupertinoColors.systemRed : CupertinoTheme.of(context).primaryColor, context, ), - ).merge(textStyle); + ).merge(widget.textStyle); - if (isDefaultAction) { + if (widget.isDefaultAction) { style = style.copyWith(fontWeight: FontWeight.w600); } @@ -2047,20 +2108,19 @@ class CupertinoDialogAction extends StatelessWidget { final Widget sizedContent = _isInAccessibilityMode(context) ? _buildContentWithAccessibilitySizingPolicy( textStyle: style, - content: child, + content: widget.child, ) : _buildContentWithRegularSizingPolicy( context: context, textStyle: style, - content: child, + content: widget.child, padding: padding, ); return MouseRegion( - cursor: onPressed != null && kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, - child: GestureDetector( - excludeFromSemantics: true, - onTap: onPressed, + cursor: widget.onPressed != null && kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, + child: MetaData( + metaData: this, behavior: HitTestBehavior.opaque, child: ConstrainedBox( constraints: const BoxConstraints( diff --git a/packages/flutter/test/cupertino/action_sheet_test.dart b/packages/flutter/test/cupertino/action_sheet_test.dart index 274716beb9..e8c4daf212 100644 --- a/packages/flutter/test/cupertino/action_sheet_test.dart +++ b/packages/flutter/test/cupertino/action_sheet_test.dart @@ -1035,7 +1035,9 @@ void main() { 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); + final Offset start = tester.getTopLeft( + find.widgetWithText(CupertinoActionSheetAction, 'Button 1'), + ) + const Offset(30, 5); // Verify that the start location is within button 1. await tester.tapAt(start); expect(pressed, 1); diff --git a/packages/flutter/test/cupertino/dialog_test.dart b/packages/flutter/test/cupertino/dialog_test.dart index 5b1b517dad..5123fc5281 100644 --- a/packages/flutter/test/cupertino/dialog_test.dart +++ b/packages/flutter/test/cupertino/dialog_test.dart @@ -82,7 +82,7 @@ void main() { await gesture.up(); }); - testWidgets('Alert dialog control test', (WidgetTester tester) async { + testWidgets('Taps on button calls onPressed', (WidgetTester tester) async { bool didDelete = false; await tester.pumpWidget( @@ -121,6 +121,447 @@ void main() { expect(find.text('Delete'), findsNothing); }); + testWidgets('Can tap after scrolling', (WidgetTester tester) async { + int? wasPressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + actions: List.generate(20, (int i) => + CupertinoDialogAction( + 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; + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + actions: [ + CupertinoDialogAction( + child: const Text('One'), + onPressed: () { + expect(wasPressed, false); + wasPressed = true; + Navigator.pop(context); + }, + ), + ], + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(wasPressed, isFalse); + + await tester.tapAt( + tester.getTopLeft(find.text('One')) - const Offset(20, 0), + ); + + expect(wasPressed, isTrue); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('One'), findsNothing); + }); + + testWidgets('Taps on a button can be slided to other buttons', (WidgetTester tester) async { + int? pressed; + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + actions: [ + CupertinoDialogAction( + child: const Text('One'), + onPressed: () { + expect(pressed, null); + pressed = 1; + Navigator.pop(context); + }, + ), + CupertinoDialogAction( + 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(CupertinoAlertDialog), + matchesGoldenFile('cupertinoAlertDialog.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( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('The title'), + actions: [ + CupertinoDialogAction( + child: const Text('One'), + 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('One'))); + 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( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('The title'), + actions: [ + CupertinoDialogAction( + 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( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + content: Text('Long message' * 200), + actions: List.generate(10, (int i) => + CupertinoDialogAction( + 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( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + content: Text('Long message' * 200), + actions: List.generate(10, (int i) => + CupertinoDialogAction( + 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.widgetWithText(CupertinoDialogAction, 'Button 1'), + ) + const Offset(30, 5); + // 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( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('The title'), + actions: List.generate(8, (int i) => + CupertinoDialogAction( + 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( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + actions: List.generate(12, (int i) => + CupertinoDialogAction( + 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('Taps on legacy button calls onPressed and renders correctly', (WidgetTester tester) async { + // Legacy buttons are implemented with [GestureDetector.onTap]. Apps that + // use customized legacy buttons should continue to work. + bool wasPressed = false; + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + actions: [ + LegacyAction( + child: const Text('Legacy'), + onPressed: () { + expect(wasPressed, false); + wasPressed = true; + Navigator.pop(context); + }, + ), + CupertinoDialogAction(child: const Text('One'), onPressed: () {}), + CupertinoDialogAction(child: const Text('Two'), onPressed: () {}), + ], + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(wasPressed, isFalse); + + // Push the legacy button and hold for a while to activate the pressing effect. + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Legacy'))); + await tester.pump(const Duration(seconds: 1)); + expect(wasPressed, isFalse); + await expectLater( + find.byType(CupertinoAlertDialog), + matchesGoldenFile('cupertinoAlertDialog.legacyButton.png'), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(wasPressed, isTrue); + expect(find.text('Legacy'), findsNothing); + }); + testWidgets('Dialog not barrier dismissible by default', (WidgetTester tester) async { await tester.pumpWidget(createAppWithCenteredButton(const Text('Go'))); @@ -360,6 +801,94 @@ void main() { expect(widget.style.color!.opacity, equals(1.0)); }); + testWidgets('Pressing on disabled buttons does not trigger highlight', (WidgetTester tester) async { + bool pressedEnable = false; + await tester.pumpWidget( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + actions: [ + const CupertinoDialogAction(child: Text('Disabled')), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () { + pressedEnable = true; + Navigator.pop(context); + }, + child: const Text('Enabled'), + ), + ], + ); + }, + ), + ); + + await tester.tap(find.text('Go')); + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Disabled'))); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // This should look exactly like an idle dialog. + await expectLater( + find.byType(CupertinoAlertDialog), + matchesGoldenFile('cupertinoAlertDialog.press_disabled.png'), + ); + + // Verify that gestures that started on a disabled button can slide onto an + // enabled button. + await gesture.moveTo(tester.getCenter(find.text('Enabled'))); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(CupertinoAlertDialog), + matchesGoldenFile('cupertinoAlertDialog.press_disabled_slide_to_enabled.png'), + ); + + expect(pressedEnable, false); + await gesture.up(); + expect(pressedEnable, true); + }); + + testWidgets('Action buttons shows pressed highlight 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( + createAppWithButtonThatLaunchesDialog( + dialogBuilder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('The title'), + actions: [ + CupertinoDialogAction( + child: const Text('One'), + onPressed: () { }, + ), + CupertinoDialogAction( + 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(CupertinoAlertDialog), + matchesGoldenFile('cupertinoAlertDialog.pressed.png'), + ); + await pointer.up(); + }); + testWidgets('Message is scrollable, has correct padding with large text sizes', (WidgetTester tester) async { final ScrollController scrollController = ScrollController(); addTearDown(scrollController.dispose); @@ -1618,3 +2147,32 @@ class TestScaffoldAppState extends State { ); } } + +// Old-style action sheet buttons, which are implemented with +// `GestureDetector.onTap`. +class LegacyAction extends StatelessWidget { + const LegacyAction({ + super.key, + required this.onPressed, + required this.child, + }); + + final VoidCallback onPressed; + final Widget child; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onPressed, + behavior: HitTestBehavior.opaque, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 45), + child: Container( + alignment: AlignmentDirectional.center, + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 10.0), + child: child, + ), + ), + ); + } +}