[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:
parent
93eabf3558
commit
c57f99e419
@ -462,6 +462,7 @@ class _CupertinoAlertDialogState extends State<CupertinoAlertDialog> {
|
||||
width: isInAccessibilityMode
|
||||
? _kAccessibilityCupertinoDialogWidth
|
||||
: _kCupertinoDialogWidth,
|
||||
child: _ActionSheetGestureDetector(
|
||||
child: CupertinoPopupSurface(
|
||||
isSurfacePainted: false,
|
||||
child: Semantics(
|
||||
@ -476,6 +477,7 @@ class _CupertinoAlertDialogState extends State<CupertinoAlertDialog> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -667,9 +669,9 @@ class _SlidingTapGestureRecognizer extends VerticalDragGestureRecognizer {
|
||||
// `_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.
|
||||
// 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();
|
||||
}
|
||||
|
||||
void onTapUp(TapUpDetails details) {
|
||||
onPressStateChange?.call(false);
|
||||
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 onTapCancel() {
|
||||
onPressStateChange?.call(false);
|
||||
// |_SlideTarget|
|
||||
@override
|
||||
bool didEnter({required bool fromPointerDown, required bool innerEnabled}) {
|
||||
widget.onPressStateChange?.call(innerEnabled);
|
||||
if (innerEnabled && !fromPointerDown) {
|
||||
_emitVibration();
|
||||
}
|
||||
return innerEnabled;
|
||||
}
|
||||
|
||||
// |_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(
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user