[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<bool> 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.
This commit is contained in:
Tong Mu 2024-09-17 13:16:18 -07:00 committed by GitHub
parent 93eabf3558
commit c57f99e419
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 674 additions and 54 deletions

View File

@ -462,14 +462,16 @@ class _CupertinoAlertDialogState extends State<CupertinoAlertDialog> {
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<CupertinoActionSheetAction>
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<CupertinoDialogAction> createState() => _CupertinoDialogActionState();
}
class _CupertinoDialogActionState extends State<CupertinoDialogAction>
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(

View File

@ -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);

View File

@ -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<Widget>.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: <Widget>[
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: <Widget>[
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: <Widget>[
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: <Widget>[
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<Widget>.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<Widget>.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<Widget>.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<Widget>.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: <Widget>[
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: <Widget>[
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: <Widget>[
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<TestScaffoldApp> {
);
}
}
// 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,
),
),
);
}
}