mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
[CupertinoActionSheet] Add sliding tap gesture (#149471)
This PR implements the `CupertinoActionSheet` part of https://github.com/flutter/flutter/issues/19786. This PR creates a new kind of gesture "sliding tap", which can be a simple tap but also allows the user to slide to other buttons during the tap, and select the button that the slide ends in. The following video shows the behavior on a button list (left to right: Native iOS, after PR, before PR): https://github.com/flutter/flutter/assets/1596656/1718630d-6890-4833-908b-762332a39568 Notice: 1. When the tap starts on a button or on the content section, the gesture can slide into a button to highlight it or activate it. 2. When the user performs a quick tap on a button, the button is immediately highlighted. (Before the PR, the highlight waits until the time out of a tap gesture, causing a quick tap to not highlight anything at all.) The following video shows the behavior when the actions section scrolls (left to right: Native iOS, after PR) https://github.com/flutter/flutter/assets/1596656/5eb70bc1-ec25-4376-9500-2aaa12f0034b Notice: 1. Moving left or right doesn't cancel sliding tap, but moving vertically prioritizes the scrolling. 2. Moving before the drag starts is also recognized as a sliding tap. Also, multiple pointers interact with the button list following an algorithm of "using earliest pointer". I can't record videos about it but I've added unit tests.
This commit is contained in:
parent
27972ebb48
commit
87f68a8876
@ -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,
|
||||
|
@ -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(
|
||||
|
Loading…
Reference in New Issue
Block a user