Commit f64bfba8 authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

Tapping a ScrollView during overscroll got it stuck. (#9721)

Fixes https://github.com/flutter/flutter/issues/8476

More detailed list of changes in this patch:

* Replaced the didTouch special logic with more generic logic that
  uses Activities instead. Now instead when you tap down the
  Scrollable calls `hold()` which begins a HoldScrollActivity which is
  a hybrid of DragStartDetails and IdleScrollActivity and can be
  canceled. When you let go, it gets canceled and that goes ballistic.

* Make DragGestureRecognizer more aggressive about grabbing pointers,
  otherwise a second pointer in a situation with competing horizontal
  and vertical recognizers always gets taken by the other one.

* Fixed the _GestureSemantics widget to call the "down" callbacks so
  that it follows the same pattern as "real" interactions.

* Added tests for the above.

* Added a hashCode to ScrollActivity.toString (and subclasses).

* Added a toString to ScrollDragController, and include it in
  DragScrollActivity's toString.

* s/coorindator/coordinator/

* Add a comment in DragStartDetails to distinguish it from the
  otherwise identical DragDownDetails, so we're not tempted to merge
  them.
parent 85b2b869
......@@ -62,6 +62,10 @@ class DragStartDetails {
/// Defaults to the origin if not specified in the constructor.
final Offset globalPosition;
// TODO(ianh): Expose the current position, so that you can have a no-jump
// drag even when disambiguating (though of course it would lag the finger
// instead).
@override
String toString() => '$runtimeType($globalPosition)';
}
......
......@@ -116,6 +116,8 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
_pendingDragOffset = Offset.zero;
if (onDown != null)
invokeCallback<Null>('onDown', () => onDown(new DragDownDetails(globalPosition: _initialPosition))); // ignore: STRONG_MODE_INVALID_CAST_FUNCTION_EXPR, https://github.com/dart-lang/sdk/issues/27504
} else if (_state == _DragState.accepted) {
resolve(GestureDisposition.accepted);
}
}
......
......@@ -504,6 +504,8 @@ class _GestureSemantics extends SingleChildRenderObjectWidget {
{
final HorizontalDragGestureRecognizer recognizer = owner._recognizers[HorizontalDragGestureRecognizer];
if (recognizer != null) {
if (recognizer.onDown != null)
recognizer.onDown(new DragDownDetails());
if (recognizer.onStart != null)
recognizer.onStart(new DragStartDetails());
if (recognizer.onUpdate != null)
......@@ -516,6 +518,8 @@ class _GestureSemantics extends SingleChildRenderObjectWidget {
{
final PanGestureRecognizer recognizer = owner._recognizers[PanGestureRecognizer];
if (recognizer != null) {
if (recognizer.onDown != null)
recognizer.onDown(new DragDownDetails());
if (recognizer.onStart != null)
recognizer.onStart(new DragStartDetails());
if (recognizer.onUpdate != null)
......@@ -531,6 +535,8 @@ class _GestureSemantics extends SingleChildRenderObjectWidget {
{
final VerticalDragGestureRecognizer recognizer = owner._recognizers[VerticalDragGestureRecognizer];
if (recognizer != null) {
if (recognizer.onDown != null)
recognizer.onDown(new DragDownDetails());
if (recognizer.onStart != null)
recognizer.onStart(new DragStartDetails());
if (recognizer.onUpdate != null)
......@@ -543,6 +549,8 @@ class _GestureSemantics extends SingleChildRenderObjectWidget {
{
final PanGestureRecognizer recognizer = owner._recognizers[PanGestureRecognizer];
if (recognizer != null) {
if (recognizer.onDown != null)
recognizer.onDown(new DragDownDetails());
if (recognizer.onStart != null)
recognizer.onStart(new DragStartDetails());
if (recognizer.onUpdate != null)
......
......@@ -74,12 +74,12 @@ class NestedScrollView extends StatefulWidget {
}
class _NestedScrollViewState extends State<NestedScrollView> {
_NestedScrollCoorindator _coordinator;
_NestedScrollCoordinator _coordinator;
@override
void initState() {
super.initState();
_coordinator = new _NestedScrollCoorindator(context, widget.initialScrollOffset);
_coordinator = new _NestedScrollCoordinator(context, widget.initialScrollOffset);
}
@override
......@@ -134,8 +134,8 @@ class _NestedScrollMetrics extends FixedScrollMetrics {
typedef ScrollActivity _NestedScrollActivityGetter(_NestedScrollPosition position);
class _NestedScrollCoorindator implements ScrollActivityDelegate {
_NestedScrollCoorindator(this._context, double initialScrollOffset) {
class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldController {
_NestedScrollCoordinator(this._context, double initialScrollOffset) {
_outerController = new _NestedScrollController(this, initialScrollOffset: initialScrollOffset, debugLabel: 'outer');
_innerController = new _NestedScrollController(this, initialScrollOffset: initialScrollOffset, debugLabel: 'inner');
}
......@@ -408,10 +408,17 @@ class _NestedScrollCoorindator implements ScrollActivityDelegate {
return 0.0;
}
void didTouch() {
_outerPosition._propagateTouched();
for (_NestedScrollPosition position in _innerPositions)
position._propagateTouched();
ScrollHoldController hold(VoidCallback holdCancelCallback) {
beginActivity(
new HoldScrollActivity(delegate: _outerPosition, onHoldCanceled: holdCancelCallback),
(_NestedScrollPosition position) => new HoldScrollActivity(delegate: position),
);
return this;
}
@override
void cancel() {
goBallistic(0.0);
}
Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
......@@ -484,12 +491,12 @@ class _NestedScrollCoorindator implements ScrollActivityDelegate {
}
class _NestedScrollController extends ScrollController {
_NestedScrollController(this.coorindator, {
_NestedScrollController(this.coordinator, {
double initialScrollOffset: 0.0,
String debugLabel,
}) : super(initialScrollOffset: initialScrollOffset, debugLabel: debugLabel);
final _NestedScrollCoorindator coorindator;
final _NestedScrollCoordinator coordinator;
@override
ScrollPosition createScrollPosition(
......@@ -498,7 +505,7 @@ class _NestedScrollController extends ScrollController {
ScrollPosition oldPosition,
) {
return new _NestedScrollPosition(
coorindator: coorindator,
coordinator: coordinator,
physics: physics,
context: context,
initialPixels: initialScrollOffset,
......@@ -511,8 +518,8 @@ class _NestedScrollController extends ScrollController {
void attach(ScrollPosition position) {
assert(position is _NestedScrollPosition);
super.attach(position);
coorindator.updateParent();
coorindator.updateCanDrag();
coordinator.updateParent();
coordinator.updateCanDrag();
}
Iterable<_NestedScrollPosition> get nestedPositions sync* {
......@@ -527,7 +534,7 @@ class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDele
double initialPixels: 0.0,
ScrollPosition oldPosition,
String debugLabel,
@required this.coorindator,
@required this.coordinator,
}) : super(
physics: physics,
context: context,
......@@ -541,7 +548,7 @@ class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDele
assert(activity != null);
}
final _NestedScrollCoorindator coorindator;
final _NestedScrollCoordinator coordinator;
TickerProvider get vsync => context.vsync;
......@@ -603,7 +610,7 @@ class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDele
}
@override
ScrollDirection get userScrollDirection => coorindator.userScrollDirection;
ScrollDirection get userScrollDirection => coordinator.userScrollDirection;
DrivenScrollActivity createDrivenScrollActivity(double to, Duration duration, Curve curve) {
return new DrivenScrollActivity(
......@@ -652,9 +659,9 @@ class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDele
assert(metrics != null);
if (metrics.minRange == metrics.maxRange)
return new IdleScrollActivity(this);
return new _NestedOuterBallisticScrollActivity(coorindator, this, metrics, simulation, context.vsync);
return new _NestedOuterBallisticScrollActivity(coordinator, this, metrics, simulation, context.vsync);
case _NestedBallisticScrollActivityMode.inner:
return new _NestedInnerBallisticScrollActivity(coorindator, this, simulation, context.vsync);
return new _NestedInnerBallisticScrollActivity(coordinator, this, simulation, context.vsync);
case _NestedBallisticScrollActivityMode.independent:
return new BallisticScrollActivity(this, simulation, context.vsync);
}
......@@ -666,12 +673,12 @@ class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDele
@required Duration duration,
@required Curve curve,
}) {
return coorindator.animateTo(coorindator.unnestOffset(to, this), duration: duration, curve: curve);
return coordinator.animateTo(coordinator.unnestOffset(to, this), duration: duration, curve: curve);
}
@override
void jumpTo(double value) {
return coorindator.jumpTo(coorindator.unnestOffset(value, this));
return coordinator.jumpTo(coordinator.unnestOffset(value, this));
}
@override
......@@ -692,7 +699,7 @@ class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDele
@override
void applyNewDimensions() {
super.applyNewDimensions();
coorindator.updateCanDrag();
coordinator.updateCanDrag();
}
void updateCanDrag(double totalExtent) {
......@@ -700,17 +707,13 @@ class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDele
}
@override
void didTouch() {
coorindator.didTouch();
}
void _propagateTouched() {
activity.didTouch();
ScrollHoldController hold(VoidCallback holdCancelCallback) {
return coordinator.hold(holdCancelCallback);
}
@override
Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
return coorindator.drag(details, dragCancelCallback);
return coordinator.drag(details, dragCancelCallback);
}
@override
......@@ -724,36 +727,36 @@ enum _NestedBallisticScrollActivityMode { outer, inner, independent }
class _NestedInnerBallisticScrollActivity extends BallisticScrollActivity {
_NestedInnerBallisticScrollActivity(
this.coorindator,
this.coordinator,
_NestedScrollPosition position,
Simulation simulation,
TickerProvider vsync,
) : super(position, simulation, vsync);
final _NestedScrollCoorindator coorindator;
final _NestedScrollCoordinator coordinator;
@override
_NestedScrollPosition get delegate => super.delegate;
@override
void resetActivity() {
delegate.beginActivity(coorindator.createInnerBallisticScrollActivity(delegate, velocity));
delegate.beginActivity(coordinator.createInnerBallisticScrollActivity(delegate, velocity));
}
@override
void applyNewDimensions() {
delegate.beginActivity(coorindator.createInnerBallisticScrollActivity(delegate, velocity));
delegate.beginActivity(coordinator.createInnerBallisticScrollActivity(delegate, velocity));
}
@override
bool applyMoveTo(double value) {
return super.applyMoveTo(coorindator.nestOffset(value, delegate));
return super.applyMoveTo(coordinator.nestOffset(value, delegate));
}
}
class _NestedOuterBallisticScrollActivity extends BallisticScrollActivity {
_NestedOuterBallisticScrollActivity(
this.coorindator,
this.coordinator,
_NestedScrollPosition position,
this.metrics,
Simulation simulation,
......@@ -763,7 +766,7 @@ class _NestedOuterBallisticScrollActivity extends BallisticScrollActivity {
assert(metrics.maxRange > metrics.minRange);
}
final _NestedScrollCoorindator coorindator;
final _NestedScrollCoordinator coordinator;
final _NestedScrollMetrics metrics;
@override
......@@ -771,12 +774,12 @@ class _NestedOuterBallisticScrollActivity extends BallisticScrollActivity {
@override
void resetActivity() {
delegate.beginActivity(coorindator.createOuterBallisticScrollActivity(velocity));
delegate.beginActivity(coordinator.createOuterBallisticScrollActivity(velocity));
}
@override
void applyNewDimensions() {
delegate.beginActivity(coorindator.createOuterBallisticScrollActivity(velocity));
delegate.beginActivity(coordinator.createOuterBallisticScrollActivity(velocity));
}
@override
......
......@@ -104,9 +104,6 @@ abstract class ScrollActivity {
new ScrollEndNotification(metrics: metrics, context: context).dispatch(context);
}
/// Called when the user touches the scroll view that is performing this activity.
void didTouch() { }
/// Called when the scroll view that is performing this activity changes its metrics.
void applyNewDimensions() { }
......@@ -127,12 +124,15 @@ abstract class ScrollActivity {
}
@override
String toString() => '$runtimeType';
String toString() => '$runtimeType#$hashCode';
}
/// A scroll activity that does nothing.
///
/// When a scroll view is not scrolling, it is performing the idle activity.
///
/// If the [Scrollable] changes dimensions, this activity triggers a ballistic
/// activity to restore the view.
class IdleScrollActivity extends ScrollActivity {
/// Creates a scroll activity that does nothing.
IdleScrollActivity(ScrollActivityDelegate delegate) : super(delegate);
......@@ -149,6 +149,56 @@ class IdleScrollActivity extends ScrollActivity {
bool get isScrolling => false;
}
/// Interface for holding a [Scrollable] stationary.
///
/// An object that implements this interface is returned by
/// [ScrollPosition.hold]. It holds the scrollable stationary until an activity
/// is started or the [cancel] method is called.
abstract class ScrollHoldController {
/// Release the [Scrollable], potentially letting it go ballistic if
/// necessary.
void cancel();
}
/// A scroll activity that does nothing but can be released to resume
/// normal idle behavior.
///
/// This is used while the user is touching the [Scrollable] but before the
/// touch has become a [Drag].
///
/// For the purposes of [ScrollNotification]s, this activity does not constitute
/// scrolling, and does not prevent the user from interacting with the contents
/// of the [Scrollable] (unlike when a drag has begun or there is a scroll
/// animation underway).
class HoldScrollActivity extends ScrollActivity implements ScrollHoldController {
/// Creates a scroll activity that does nothing.
HoldScrollActivity({
@required ScrollActivityDelegate delegate,
this.onHoldCanceled,
}) : super(delegate);
/// Called when [dispose] is called.
final VoidCallback onHoldCanceled;
@override
bool get shouldIgnorePointer => false;
@override
bool get isScrolling => false;
@override
void cancel() {
delegate.goBallistic(0.0);
}
@override
void dispose() {
if (onHoldCanceled != null)
onHoldCanceled();
super.dispose();
}
}
/// Scrolls a scroll view as the user drags their finger across the screen.
///
/// See also:
......@@ -217,7 +267,7 @@ class ScrollDragController implements Drag {
delegate.goBallistic(0.0);
}
/// Called when the delegate is no longer sending events to this object.
/// Called by the delegate when it is no longer sending events to this object.
@mustCallSuper
void dispose() {
_lastDetails = null;
......@@ -229,6 +279,11 @@ class ScrollDragController implements Drag {
/// [DragEndDetails] object.
dynamic get lastDetails => _lastDetails;
dynamic _lastDetails;
@override
String toString() {
return '$runtimeType#$hashCode';
}
}
/// The activity a scroll view performs when a the user drags their finger
......@@ -248,11 +303,6 @@ class DragScrollActivity extends ScrollActivity {
ScrollDragController _controller;
@override
void didTouch() {
assert(false);
}
@override
void dispatchScrollStartNotification(ScrollMetrics metrics, BuildContext context) {
final dynamic lastDetails = _controller.lastDetails;
......@@ -296,6 +346,11 @@ class DragScrollActivity extends ScrollActivity {
_controller = null;
super.dispose();
}
@override
String toString() {
return '$runtimeType#$hashCode($_controller)';
}
}
/// An activity that animates a scroll view based on a physics [simulation].
......@@ -340,11 +395,6 @@ class BallisticScrollActivity extends ScrollActivity {
delegate.goBallistic(velocity);
}
@override
void didTouch() {
delegate.goIdle();
}
@override
void applyNewDimensions() {
delegate.goBallistic(velocity);
......@@ -390,7 +440,7 @@ class BallisticScrollActivity extends ScrollActivity {
@override
String toString() {
return '$runtimeType($_controller)';
return '$runtimeType#$hashCode($_controller)';
}
}
......@@ -446,11 +496,6 @@ class DrivenScrollActivity extends ScrollActivity {
/// pixels per second).
double get velocity => _controller.velocity;
@override
void didTouch() {
delegate.goIdle();
}
void _tick() {
if (delegate.setPixels(_controller.value) != 0.0)
delegate.goIdle();
......@@ -480,6 +525,6 @@ class DrivenScrollActivity extends ScrollActivity {
@override
String toString() {
return '$runtimeType($_controller)';
return '$runtimeType#$hashCode($_controller)';
}
}
......@@ -18,6 +18,8 @@ import 'scroll_metrics.dart';
import 'scroll_notification.dart';
import 'scroll_physics.dart';
export 'scroll_activity.dart' show ScrollHoldController;
// ## Subclassing ScrollPosition
//
// * Describe how to impelement [absorb]
......@@ -303,7 +305,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
@Deprecated('This will lead to bugs.')
void jumpToWithoutSettling(double value);
void didTouch();
ScrollHoldController hold(VoidCallback holdCancelCallback);
Drag drag(DragStartDetails details, VoidCallback dragCancelCallback);
......
......@@ -287,16 +287,18 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc
}
}
/// Inform the current activity that the user touched the area to which this
/// object relates.
ScrollDragController _currentDrag;
@override
void didTouch() {
assert(activity != null);
activity.didTouch();
ScrollHoldController hold(VoidCallback holdCancelCallback) {
final HoldScrollActivity activity = new HoldScrollActivity(
delegate: this,
onHoldCanceled: holdCancelCallback,
);
beginActivity(activity);
return activity;
}
ScrollDragController _currentDrag;
/// Start a drag activity corresponding to the given [DragStartDetails].
///
/// The `onDragCanceled` argument will be invoked if the drag is ended
......
......@@ -361,35 +361,49 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
// TOUCH HANDLERS
Drag _drag;
ScrollHoldController _hold;
void _handleDragDown(DragDownDetails details) {
assert(_drag == null);
position.didTouch();
assert(_hold == null);
_hold = position.hold(_disposeHold);
}
void _handleDragStart(DragStartDetails details) {
assert(_drag == null);
assert(_hold != null);
_drag = position.drag(details, _disposeDrag);
assert(_drag != null);
assert(_hold == null);
}
void _handleDragUpdate(DragUpdateDetails details) {
// _drag might be null if the drag activity ended and called _disposeDrag.
assert(_hold == null || _drag == null);
_drag?.update(details);
}
void _handleDragEnd(DragEndDetails details) {
// _drag might be null if the drag activity ended and called _disposeDrag.
assert(_hold == null || _drag == null);
_drag?.end(details);
assert(_drag == null);
}
void _handleDragCancel() {
// _hold might be null if the drag started.
// _drag might be null if the drag activity ended and called _disposeDrag.
assert(_hold == null || _drag == null);
_hold?.cancel();
_drag?.cancel();
assert(_hold == null);
assert(_drag == null);
}
void _disposeHold() {
_hold = null;
}
void _disposeDrag() {
_drag = null;
}
......
......@@ -129,6 +129,75 @@ void main() {
drag.dispose();
});
testGesture('Drag with multiple pointers', (GestureTester tester) {
final HorizontalDragGestureRecognizer drag1 = new HorizontalDragGestureRecognizer();
final VerticalDragGestureRecognizer drag2 = new VerticalDragGestureRecognizer();
final List<String> log = <String>[];
drag1.onDown = (_) { log.add('drag1-down'); };
drag1.onStart = (_) { log.add('drag1-start'); };
drag1.onUpdate = (_) { log.add('drag1-update'); };
drag1.onEnd = (_) { log.add('drag1-end'); };
drag1.onCancel = () { log.add('drag1-cancel'); };
drag2.onDown = (_) { log.add('drag2-down'); };
drag2.onStart = (_) { log.add('drag2-start'); };
drag2.onUpdate = (_) { log.add('drag2-update'); };
drag2.onEnd = (_) { log.add('drag2-end'); };
drag2.onCancel = () { log.add('drag2-cancel'); };
final TestPointer pointer5 = new TestPointer(5);
final PointerDownEvent down5 = pointer5.down(const Offset(10.0, 10.0));
drag1.addPointer(down5);
drag2.addPointer(down5);
tester.closeArena(5);
tester.route(down5);
log.add('-a');
tester.route(pointer5.move(const Offset(100.0, 0.0)));
log.add('-b');
tester.route(pointer5.move(const Offset(50.0, 50.0)));
log.add('-c');
final TestPointer pointer6 = new TestPointer(6);
final PointerDownEvent down6 = pointer6.down(const Offset(20.0, 20.0));
drag1.addPointer(down6);
drag2.addPointer(down6);
tester.closeArena(6);
tester.route(down6);
log.add('-d');
tester.route(pointer5.move(const Offset(0.0, 100.0)));
log.add('-e');
tester.route(pointer5.move(const Offset(70.0, 70.0)));
log.add('-f');
tester.route(pointer5.up());
tester.route(pointer6.up());
drag1.dispose();
drag2.dispose();
expect(log, <String>[
'drag1-down',
'drag2-down',
'-a',
'drag2-cancel',
'drag1-start',
'drag1-update',
'-b',
'drag1-update',
'-c',
'drag2-down',
'drag2-cancel',
'-d',
'drag1-update',
'-e',
'drag1-update',
'-f',
'drag1-end'
]);
});
testGesture('Clamp max velocity', (GestureTester tester) {
final HorizontalDragGestureRecognizer drag = new HorizontalDragGestureRecognizer();
......
......@@ -139,11 +139,35 @@ void main() {
final List<String> log = <String>[];
await tester.pumpWidget(_buildScroller(log: log));
// The ideal behaviour here would be a single start/end pair, but for
// simplicity of implementation we compromise here and accept two. Should
// you find a way to make this work with just one without complicating the
// API, feel free to change the expectation here.
expect(log, equals(<String>[]));
await tester.flingFrom(const Offset(100.0, 100.0), const Offset(-50.0, -50.0), 500.0);
await tester.pump(const Duration(seconds: 1));
log.removeWhere((String value) => value == 'scroll-update');
expect(log, equals(<String>['scroll-start']));
await tester.flingFrom(const Offset(100.0, 100.0), const Offset(-50.0, -50.0), 500.0);
log.removeWhere((String value) => value == 'scroll-update');
expect(log, equals(<String>['scroll-start', 'scroll-end', 'scroll-start']));
await tester.pump(const Duration(seconds: 1));
await tester.pump(const Duration(seconds: 1));
log.removeWhere((String value) => value == 'scroll-update');
expect(log, equals(<String>['scroll-start', 'scroll-end', 'scroll-start', 'scroll-end']));
});
testWidgets('fling, pause, fling generates two start/end pairs', (WidgetTester tester) async {
final List<String> log = <String>[];
await tester.pumpWidget(_buildScroller(log: log));
expect(log, equals(<String>[]));
await tester.flingFrom(const Offset(100.0, 100.0), const Offset(-50.0, -50.0), 500.0);
await tester.pump(const Duration(seconds: 1));
log.removeWhere((String value) => value == 'scroll-update');
expect(log, equals(<String>['scroll-start']));
await tester.pump(const Duration(minutes: 1));
await tester.flingFrom(const Offset(100.0, 100.0), const Offset(-50.0, -50.0), 500.0);
log.removeWhere((String value) => value == 'scroll-update');
expect(log, equals(<String>['scroll-start', 'scroll-end', 'scroll-start']));
......
......@@ -78,4 +78,21 @@ void main() {
expect(result1, greaterThan(result2)); // iOS (result1) is slipperier than Android (result2)
});
testWidgets('Holding scroll', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.iOS);
await tester.drag(find.byType(Viewport), const Offset(0.0, 200.0));
expect(getScrollOffset(tester), -200.0);
await tester.pump(); // trigger ballistic
await tester.pump(const Duration(milliseconds: 10));
expect(getScrollOffset(tester), greaterThan(-200.0));
expect(getScrollOffset(tester), lessThan(0.0));
final double position = getScrollOffset(tester);
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport)));
expect(await tester.pumpAndSettle(), 1);
expect(getScrollOffset(tester), position);
await gesture.up();
expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2);
expect(getScrollOffset(tester), 0.0);
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment