diff --git a/package/lib/src/foundation/sheet_activity.dart b/package/lib/src/foundation/sheet_activity.dart index 21fc246f..21322980 100644 --- a/package/lib/src/foundation/sheet_activity.dart +++ b/package/lib/src/foundation/sheet_activity.dart @@ -88,14 +88,19 @@ abstract class SheetActivity { case > 0: // Prevents the sheet from being pushed off the screen by the keyboard. final correction = min(0.0, metrics.maxViewPixels - metrics.viewPixels); - owner.setPixels(oldPixels + correction); + owner + ..setPixels(oldPixels + correction) + ..didUpdateMetrics(); case < 0: // Appends the delta of the bottom inset (typically the keyboard height) // to keep the visual sheet position unchanged. - owner.setPixels( - min(oldPixels - deltaInsetBottom, owner.metrics.maxPixels), - ); + owner + ..setPixels(min( + oldPixels - deltaInsetBottom, + owner.metrics.maxPixels, + )) + ..didUpdateMetrics(); } owner.settle(); @@ -177,7 +182,9 @@ class AnimatedSheetActivity extends SheetActivity final newInsets = owner.metrics.viewportInsets; final oldInsets = oldViewportInsets ?? newInsets; final deltaInsetBottom = newInsets.bottom - oldInsets.bottom; - owner.setPixels(owner.metrics.pixels - deltaInsetBottom); + owner + ..setPixels(owner.metrics.pixels - deltaInsetBottom) + ..didUpdateMetrics(); // 2. If the animation is still running, we start a new linear animation // to bring the sheet position to the recalculated final position in the @@ -252,17 +259,27 @@ class DragSheetActivity extends SheetActivity } @override - void applyUserDragUpdate(Offset offset) { + void applyUserDragUpdate(SheetDragUpdateDetails details) { final physicsAppliedDelta = - owner.physics.applyPhysicsToOffset(offset.dy, owner.metrics); + owner.physics.applyPhysicsToOffset(details.deltaY, owner.metrics); if (physicsAppliedDelta != 0) { - owner.setPixels(owner.metrics.pixels + physicsAppliedDelta); + owner + ..setPixels(owner.metrics.pixels + physicsAppliedDelta) + ..didDragUpdateMetrics(details); + } + + final overflow = + owner.physics.computeOverflow(details.deltaY, owner.metrics); + if (overflow != 0) { + owner.didOverflowBy(overflow); } } @override - void applyUserDragEnd(Velocity velocity) { - owner.goBallistic(velocity.pixelsPerSecond.dy); + void applyUserDragEnd(SheetDragEndDetails details) { + owner + ..didDragEnd(details) + ..goBallistic(details.velocityY); } } @@ -298,7 +315,9 @@ mixin ControlledSheetActivityMixin on SheetActivity { void onAnimationTick() { if (mounted) { final oldPixels = owner.metrics.pixels; - owner.setPixels(oldPixels + controller.value - _lastAnimatedValue); + owner + ..setPixels(oldPixels + controller.value - _lastAnimatedValue) + ..didUpdateMetrics(); _lastAnimatedValue = controller.value; } } @@ -331,7 +350,9 @@ mixin UserControlledSheetActivityMixin final deltaInsetBottom = newInsets.bottom - oldInsets.bottom; // Appends the delta of the bottom inset (typically the keyboard height) // to keep the visual sheet position unchanged. - owner.setPixels(owner.metrics.pixels - deltaInsetBottom); + owner + ..setPixels(owner.metrics.pixels - deltaInsetBottom) + ..didUpdateMetrics(); // We don't call `goSettling` here because the user is still // manually controlling the sheet position. } diff --git a/package/lib/src/foundation/sheet_drag.dart b/package/lib/src/foundation/sheet_drag.dart index 2fbeb3ed..103af025 100644 --- a/package/lib/src/foundation/sheet_drag.dart +++ b/package/lib/src/foundation/sheet_drag.dart @@ -217,8 +217,8 @@ class SheetDragEndDetails extends SheetDragDetails { @internal abstract class SheetDragControllerTarget { VerticalDirection get dragAxisDirection; - void applyUserDragUpdate(Offset offset); - void applyUserDragEnd(Velocity velocity); + void applyUserDragUpdate(SheetDragUpdateDetails details); + void applyUserDragEnd(SheetDragEndDetails details); /// Returns the minimum number of pixels that the sheet being dragged /// will potentially consume for the given drag delta. @@ -318,7 +318,7 @@ class SheetDragController implements Drag, ScrollActivityDelegate { } _lastDetails = details; - _target!.applyUserDragEnd(details.velocity); + _target!.applyUserDragEnd(details); } /// Called by the [ScrollDragController] in [Drag.update]. @@ -349,7 +349,7 @@ class SheetDragController implements Drag, ScrollActivityDelegate { } _lastDetails = details; - _target!.applyUserDragUpdate(details.delta); + _target!.applyUserDragUpdate(details); } @override diff --git a/package/lib/src/foundation/sheet_extent.dart b/package/lib/src/foundation/sheet_extent.dart index 69b99a6a..b1fee87f 100644 --- a/package/lib/src/foundation/sheet_extent.dart +++ b/package/lib/src/foundation/sheet_extent.dart @@ -2,7 +2,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; @@ -385,24 +384,20 @@ abstract class SheetExtent extends ChangeNotifier final isDragging = activity.status == SheetStatus.dragging; // TODO: Make more typesafe - switch ((wasDragging, isDragging)) { - case (true, true): - assert(currentDrag != null); - assert(activity is SheetDragControllerTarget); - currentDrag!.updateTarget(activity as SheetDragControllerTarget); - - case (true, false): - assert(currentDrag != null); - dispatchDragEndNotification(); - currentDrag!.dispose(); - currentDrag = null; - - case (false, true): - assert(currentDrag != null); - dispatchDragStartNotification(); - - case (false, false): - assert(currentDrag == null); + assert(() { + final wasActuallyDragging = + currentDrag != null && oldActivity is SheetDragControllerTarget; + final isActuallyDragging = + currentDrag != null && activity is SheetDragControllerTarget; + return wasDragging == wasActuallyDragging && + isDragging == isActuallyDragging; + }()); + + if (wasDragging && isDragging) { + currentDrag!.updateTarget(activity as SheetDragControllerTarget); + } else if (wasDragging && !isDragging) { + currentDrag!.dispose(); + currentDrag = null; } oldActivity.dispose(); @@ -437,10 +432,7 @@ abstract class SheetExtent extends ChangeNotifier } } - Drag drag( - DragStartDetails details, - VoidCallback dragCancelCallback, - ) { + Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) { assert(currentDrag == null); final dragActivity = DragSheetActivity(); var startDetails = SheetDragStartDetails( @@ -466,6 +458,7 @@ abstract class SheetExtent extends ChangeNotifier motionStartDistanceThreshold: physics.dragStartDistanceMotionThreshold, ); beginActivity(dragActivity); + didDragStart(startDetails); return drag; } @@ -483,11 +476,6 @@ abstract class SheetExtent extends ChangeNotifier correctPixels(pixels); if (oldPixels != pixels) { notifyListeners(); - if (currentDrag?.lastDetails is SheetDragUpdateDetails) { - dispatchDragUpdateNotification(); - } else { - dispatchUpdateNotification(); - } } } @@ -522,80 +510,46 @@ abstract class SheetExtent extends ChangeNotifier } } - void dispatchUpdateNotification() { + void didUpdateMetrics() { if (metrics.hasDimensions) { - _dispatchNotification( - SheetUpdateNotification( - metrics: metrics, - status: status, - ), - ); + SheetUpdateNotification( + metrics: metrics, + status: status, + ).dispatch(context.notificationContext); } } - void dispatchDragStartNotification() { + void didDragStart(SheetDragStartDetails details) { assert(metrics.hasDimensions); - assert(currentDrag != null); - final details = currentDrag!.lastDetails; - assert(details is SheetDragStartDetails); - _dispatchNotification( - SheetDragStartNotification( - metrics: metrics, - dragDetails: details as SheetDragStartDetails, - ), - ); + SheetDragStartNotification( + metrics: metrics, + dragDetails: details, + ).dispatch(context.notificationContext); } - void dispatchDragEndNotification() { + void didDragEnd(SheetDragEndDetails details) { assert(metrics.hasDimensions); - assert(currentDrag != null); - final details = currentDrag!.lastDetails; - assert(details is SheetDragEndDetails); - _dispatchNotification( - SheetDragEndNotification( - metrics: metrics, - dragDetails: details as SheetDragEndDetails, - ), - ); + SheetDragEndNotification( + metrics: metrics, + dragDetails: details, + ).dispatch(context.notificationContext); } - void dispatchDragUpdateNotification() { + void didDragUpdateMetrics(SheetDragUpdateDetails details) { assert(metrics.hasDimensions); - assert(currentDrag != null); - final details = currentDrag!.lastDetails; - assert(details is SheetDragUpdateDetails); - _dispatchNotification( - SheetDragUpdateNotification( - metrics: metrics, - dragDetails: details as SheetDragUpdateDetails, - ), - ); + SheetDragUpdateNotification( + metrics: metrics, + dragDetails: details, + ).dispatch(context.notificationContext); } - void dispatchOverflowNotification(double overflow) { + void didOverflowBy(double overflow) { assert(metrics.hasDimensions); - _dispatchNotification( - SheetOverflowNotification( - metrics: metrics, - status: status, - overflow: overflow, - ), - ); - } - - void _dispatchNotification(SheetNotification notification) { - // Avoid dispatching a notification in the middle of a build. - switch (SchedulerBinding.instance.schedulerPhase) { - case SchedulerPhase.postFrameCallbacks: - notification.dispatch(context.notificationContext); - case SchedulerPhase.idle: - case SchedulerPhase.midFrameMicrotasks: - case SchedulerPhase.persistentCallbacks: - case SchedulerPhase.transientCallbacks: - SchedulerBinding.instance.addPostFrameCallback((_) { - notification.dispatch(context.notificationContext); - }); - } + SheetOverflowNotification( + metrics: metrics, + status: status, + overflow: overflow, + ).dispatch(context.notificationContext); } String _debugMessage(String message) { diff --git a/package/lib/src/foundation/sheet_physics.dart b/package/lib/src/foundation/sheet_physics.dart index 7b1197a7..3f6dfa97 100644 --- a/package/lib/src/foundation/sheet_physics.dart +++ b/package/lib/src/foundation/sheet_physics.dart @@ -64,6 +64,7 @@ abstract class SheetPhysics { double computeOverflow(double offset, SheetMetrics metrics); + // TODO: Change to return a tuple of (physicsAppliedOffset, overflow) to avoid recomputation of the overflow. double applyPhysicsToOffset(double offset, SheetMetrics metrics); Simulation? createBallisticSimulation(double velocity, SheetMetrics metrics); @@ -92,6 +93,7 @@ mixin SheetPhysicsMixin on SheetPhysics { @override double applyPhysicsToOffset(double offset, SheetMetrics metrics) { + // TODO: Use computeOverflow() to calculate the overflowed pixels. if (parent case final parent?) { return parent.applyPhysicsToOffset(offset, metrics); } else if (offset > 0 && metrics.pixels < metrics.maxPixels) { diff --git a/package/lib/src/navigation/navigation_sheet_extent.dart b/package/lib/src/navigation/navigation_sheet_extent.dart index bdf38a50..cd197fb6 100644 --- a/package/lib/src/navigation/navigation_sheet_extent.dart +++ b/package/lib/src/navigation/navigation_sheet_extent.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; +import '../foundation/sheet_drag.dart'; import '../foundation/sheet_extent.dart'; import '../internal/transition_observer.dart'; import 'navigation_route.dart'; @@ -102,42 +103,42 @@ class NavigationSheetExtent extends SheetExtent { } @override - void dispatchUpdateNotification() { + void didUpdateMetrics() { // Do not dispatch a notifications if a local extent is active. if (activity is! NavigationSheetActivity) { - super.dispatchUpdateNotification(); + super.didUpdateMetrics(); } } @override - void dispatchDragStartNotification() { + void didDragStart(SheetDragStartDetails details) { // Do not dispatch a notifications if a local extent is active. if (activity is! NavigationSheetActivity) { - super.dispatchDragStartNotification(); + super.didDragStart(details); } } @override - void dispatchDragEndNotification() { + void didDragEnd(SheetDragEndDetails details) { // Do not dispatch a notifications if a local extent is active. if (activity is! NavigationSheetActivity) { - super.dispatchDragEndNotification(); + super.didDragEnd(details); } } @override - void dispatchDragUpdateNotification() { + void didDragUpdateMetrics(SheetDragUpdateDetails details) { // Do not dispatch a notifications if a local extent is active. if (activity is! NavigationSheetActivity) { - super.dispatchDragUpdateNotification(); + super.didDragUpdateMetrics(details); } } @override - void dispatchOverflowNotification(double overflow) { + void didOverflowBy(double overflow) { // Do not dispatch a notifications if a local extent is active. if (activity is! NavigationSheetActivity) { - super.dispatchOverflowNotification(overflow); + super.didOverflowBy(overflow); } } diff --git a/package/lib/src/scrollable/scrollable_sheet_activity.dart b/package/lib/src/scrollable/scrollable_sheet_activity.dart index cdd38b2d..ab4c7a7e 100644 --- a/package/lib/src/scrollable/scrollable_sheet_activity.dart +++ b/package/lib/src/scrollable/scrollable_sheet_activity.dart @@ -12,6 +12,18 @@ import 'scrollable_sheet_extent.dart'; import 'sheet_content_scroll_activity.dart'; import 'sheet_content_scroll_position.dart'; +/// A [SheetActivity] that is associated with a [SheetContentScrollPosition]. +/// +/// This activity is responsible for both scrolling a scrollable content +/// in the sheet and dragging the sheet itself. +/// +/// [shouldIgnorePointer] and [SheetContentScrollPosition.shouldIgnorePointer] +/// of the associated scroll position may be synchronized, but not always. +/// For example, [BallisticScrollDrivenSheetActivity]'s [shouldIgnorePointer] +/// is always `false` while the associated scroll position sets it to `true` +/// in most cases to ensure that the pointer events, which potentially +/// interrupt the ballistic scroll animation, are not stolen by clickable +/// items in the scroll view. @internal abstract class ScrollableSheetActivity extends SheetActivity { @@ -111,7 +123,6 @@ abstract class ScrollableSheetActivity final overflow = owner.physics.computeOverflow(delta, owner.metrics); if (overflow.abs() > 0) { position.didOverscrollBy(overflow); - owner.dispatchOverflowNotification(overflow); return overflow; } @@ -177,19 +188,28 @@ class DragScrollDrivenSheetActivity extends ScrollableSheetActivity } @override - void applyUserDragUpdate(Offset delta) { - scrollPosition.userScrollDirection = - delta.dy > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse; - _applyScrollOffset(-1 * delta.dy); + void applyUserDragUpdate(SheetDragUpdateDetails details) { + scrollPosition.userScrollDirection = details.deltaY > 0.0 + ? ScrollDirection.forward + : ScrollDirection.reverse; + final oldPixels = owner.metrics.pixels; + final overflow = _applyScrollOffset(-1 * details.deltaY); + if (owner.metrics.pixels != oldPixels) { + owner.didDragUpdateMetrics(details); + } + if (overflow > 0) { + owner.didOverflowBy(overflow); + } } @override - void applyUserDragEnd(Velocity velocity) { - owner.goBallisticWithScrollPosition( - velocity: -1 * velocity.pixelsPerSecond.dy, - shouldIgnorePointer: false, - scrollPosition: scrollPosition, - ); + void applyUserDragEnd(SheetDragEndDetails details) { + owner + ..didDragEnd(details) + ..goBallisticWithScrollPosition( + velocity: -1 * details.velocityY, + scrollPosition: scrollPosition, + ); } } @@ -206,14 +226,10 @@ class BallisticScrollDrivenSheetActivity extends ScrollableSheetActivity super.scrollPosition, { required this.simulation, required double initialPixels, - required this.shouldIgnorePointer, }) : _oldPixels = initialPixels; final Simulation simulation; - @override - final bool shouldIgnorePointer; - double _oldPixels; @override @@ -230,10 +246,14 @@ class BallisticScrollDrivenSheetActivity extends ScrollableSheetActivity void onAnimationTick() { final delta = controller.value - _oldPixels; _oldPixels = controller.value; - final overscroll = _applyScrollOffset(delta); - - if (!overscroll.isApprox(0)) { - owner.goIdleWithScrollPosition(); + final overflow = _applyScrollOffset(delta); + if (owner.metrics.pixels != _oldPixels) { + owner.didUpdateMetrics(); + } + if (!overflow.isApprox(0)) { + owner + ..didOverflowBy(overflow) + ..goIdleWithScrollPosition(); return; } @@ -258,7 +278,6 @@ class BallisticScrollDrivenSheetActivity extends ScrollableSheetActivity void _end() { owner.goBallisticWithScrollPosition( velocity: 0, - shouldIgnorePointer: shouldIgnorePointer, scrollPosition: scrollPosition, ); } diff --git a/package/lib/src/scrollable/scrollable_sheet_extent.dart b/package/lib/src/scrollable/scrollable_sheet_extent.dart index 0768ffd9..88e5f78b 100644 --- a/package/lib/src/scrollable/scrollable_sheet_extent.dart +++ b/package/lib/src/scrollable/scrollable_sheet_extent.dart @@ -154,13 +154,13 @@ class ScrollableSheetExtent extends SheetExtent ), ); beginActivity(dragActivity); + didDragStart(startDetails); return drag; } @override void goBallisticWithScrollPosition({ required double velocity, - required bool shouldIgnorePointer, required SheetContentScrollPosition scrollPosition, }) { assert(metrics.hasDimensions); @@ -195,13 +195,12 @@ class ScrollableSheetExtent extends SheetExtent scrollPosition, simulation: scrollSimulation, initialPixels: scrollPixelsForScrollPhysics, - shouldIgnorePointer: shouldIgnorePointer, ), ); scrollPosition.beginActivity( SheetContentBallisticScrollActivity( delegate: scrollPosition, - shouldIgnorePointer: shouldIgnorePointer, + shouldIgnorePointer: scrollPosition.shouldIgnorePointer, getVelocity: () => activity.velocity, ), ); diff --git a/package/lib/src/scrollable/sheet_content_scroll_position.dart b/package/lib/src/scrollable/sheet_content_scroll_position.dart index 0bd440e9..6671d681 100644 --- a/package/lib/src/scrollable/sheet_content_scroll_position.dart +++ b/package/lib/src/scrollable/sheet_content_scroll_position.dart @@ -32,7 +32,6 @@ abstract class SheetContentScrollPositionOwner { void goBallisticWithScrollPosition({ required double velocity, - required bool shouldIgnorePointer, required SheetContentScrollPosition scrollPosition, }); } @@ -69,6 +68,10 @@ class SheetContentScrollPosition extends ScrollPositionWithSingleContext { double _heldPreviousVelocity = 0.0; double get heldPreviousVelocity => _heldPreviousVelocity; + /// Whether the scroll view should prevent its contents from receiving + /// pointer events. + bool get shouldIgnorePointer => activity!.shouldIgnorePointer; + /// Sets the user scroll direction. /// /// This exists only to expose `updateUserScrollDirection` @@ -123,7 +126,6 @@ class SheetContentScrollPosition extends ScrollPositionWithSingleContext { if (owner != null && owner.hasPrimaryScrollPosition && !calledByOwner) { owner.goBallisticWithScrollPosition( velocity: velocity, - shouldIgnorePointer: activity?.shouldIgnorePointer ?? true, scrollPosition: this, ); return; diff --git a/package/test/foundation/sheet_notification_test.dart b/package/test/foundation/sheet_notification_test.dart new file mode 100644 index 00000000..aa1541a4 --- /dev/null +++ b/package/test/foundation/sheet_notification_test.dart @@ -0,0 +1,306 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:smooth_sheets/src/draggable/draggable_sheet.dart'; +import 'package:smooth_sheets/src/foundation/sheet_controller.dart'; +import 'package:smooth_sheets/src/foundation/sheet_extent.dart'; +import 'package:smooth_sheets/src/foundation/sheet_notification.dart'; +import 'package:smooth_sheets/src/foundation/sheet_physics.dart'; +import 'package:smooth_sheets/src/foundation/sheet_status.dart'; + +void main() { + testWidgets( + 'Drag gesture should dispatch drag start/update/end notifications in sequence', + (tester) async { + final reportedNotifications = []; + const targetKey = Key('target'); + + await tester.pumpWidget( + NotificationListener( + onNotification: (notification) { + reportedNotifications.add(notification); + return false; + }, + child: DraggableSheet( + minExtent: const Extent.pixels(0), + // Disable the snapping effect + physics: const ClampingSheetPhysics(), + child: Container( + key: targetKey, + color: Colors.white, + width: double.infinity, + height: 500, + ), + ), + ), + ); + + final gesturePointer = await tester.press(find.byKey(targetKey)); + await gesturePointer.moveBy(const Offset(0, 20)); + await tester.pump(); + expect(reportedNotifications, hasLength(2)); + expect( + reportedNotifications[0], + isA() + .having((e) => e.metrics.maybePixels, 'pixels', 500) + .having((e) => e.status, 'status', SheetStatus.dragging) + .having((e) => e.dragDetails.kind, 'kind', PointerDeviceKind.touch) + .having( + (e) => e.dragDetails.localPosition, + 'localPosition', + const Offset(400, 250), + ) + .having( + (e) => e.dragDetails.globalPosition, + 'globalPosition', + const Offset(400, 350), + ), + ); + expect( + reportedNotifications[1], + isA() + .having((e) => e.metrics.maybePixels, 'pixels', 480) + .having((e) => e.status, 'status', SheetStatus.dragging) + .having( + (e) => e.dragDetails.axisDirection, + 'axisDirection', + VerticalDirection.up, + ) + .having( + (e) => e.dragDetails.localPosition, + 'localPosition', + const Offset(400, 270), + ) + .having( + (e) => e.dragDetails.globalPosition, + 'globalPosition', + const Offset(400, 370), + ), + ); + + reportedNotifications.clear(); + await gesturePointer.moveBy(const Offset(0, 20)); + await tester.pump(); + expect( + reportedNotifications.single, + isA() + .having((e) => e.metrics.maybePixels, 'pixels', 460) + .having((e) => e.status, 'status', SheetStatus.dragging) + .having( + (e) => e.dragDetails.axisDirection, + 'axisDirection', + VerticalDirection.up, + ) + .having( + (e) => e.dragDetails.localPosition, + 'localPosition', + const Offset(400, 290), + ) + .having( + (e) => e.dragDetails.globalPosition, + 'globalPosition', + const Offset(400, 390), + ), + ); + + reportedNotifications.clear(); + await gesturePointer.moveBy(const Offset(0, -20)); + await tester.pump(); + expect( + reportedNotifications.single, + isA() + .having((e) => e.metrics.maybePixels, 'pixels', 480) + .having((e) => e.status, 'status', SheetStatus.dragging) + .having( + (e) => e.dragDetails.axisDirection, + 'axisDirection', + VerticalDirection.up, + ) + .having( + (e) => e.dragDetails.localPosition, + 'localPosition', + const Offset(400, 270), + ) + .having( + (e) => e.dragDetails.globalPosition, + 'globalPosition', + const Offset(400, 370), + ), + ); + + reportedNotifications.clear(); + await gesturePointer.up(); + await tester.pump(); + expect( + reportedNotifications.single, + isA() + .having((e) => e.metrics.maybePixels, 'pixels', 480) + .having((e) => e.status, 'status', SheetStatus.dragging) + .having((e) => e.dragDetails.velocity, 'velocity', Velocity.zero) + .having( + (e) => e.dragDetails.axisDirection, + 'axisDirection', + VerticalDirection.up, + ), + ); + + reportedNotifications.clear(); + await tester.pumpAndSettle(); + expect(reportedNotifications, isEmpty, + reason: 'Once the drag is ended, ' + 'no notification should be dispatched.'); + }, + ); + + testWidgets( + 'Sheet animation should dispatch metrics update notifications', + (tester) async { + final reportedNotifications = []; + final controller = SheetController(); + + await tester.pumpWidget( + NotificationListener( + onNotification: (notification) { + reportedNotifications.add(notification); + return false; + }, + child: DraggableSheet( + controller: controller, + minExtent: const Extent.pixels(0), + // Disable the snapping effect + physics: const ClampingSheetPhysics(), + child: Container( + color: Colors.white, + width: double.infinity, + height: 600, + ), + ), + ), + ); + + unawaited( + controller.animateTo( + const Extent.pixels(0), + duration: const Duration(milliseconds: 300), + curve: Curves.linear, + ), + ); + await tester.pump(Duration.zero); + expect( + reportedNotifications.single, + isA() + .having((e) => e.metrics.pixels, 'pixels', moreOrLessEquals(600)) + .having((e) => e.status, 'status', SheetStatus.animating), + ); + + reportedNotifications.clear(); + await tester.pump(const Duration(milliseconds: 100)); + expect( + reportedNotifications.single, + isA() + .having((e) => e.metrics.pixels, 'pixels', moreOrLessEquals(400)) + .having((e) => e.status, 'status', SheetStatus.animating), + ); + + reportedNotifications.clear(); + await tester.pump(const Duration(milliseconds: 100)); + expect( + reportedNotifications.single, + isA() + .having((e) => e.metrics.pixels, 'pixels', moreOrLessEquals(200)) + .having((e) => e.status, 'status', SheetStatus.animating), + ); + + reportedNotifications.clear(); + await tester.pump(const Duration(seconds: 100)); + expect( + reportedNotifications.single, + isA() + .having((e) => e.metrics.pixels, 'pixels', moreOrLessEquals(0)) + .having((e) => e.status, 'status', SheetStatus.animating), + ); + + reportedNotifications.clear(); + await tester.pumpAndSettle(); + expect(reportedNotifications, isEmpty, + reason: 'Once the animation is finished, ' + 'no notification should be dispatched.'); + }, + ); + + testWidgets( + 'Over-darg gesture should dispatch both darg and overflow notifications', + (tester) async { + final reportedNotifications = []; + const targetKey = Key('target'); + + await tester.pumpWidget( + NotificationListener( + onNotification: (notification) { + reportedNotifications.add(notification); + return false; + }, + child: DraggableSheet( + // Make sure the sheet can't be dragged + minExtent: const Extent.proportional(1), + maxExtent: const Extent.proportional(1), + // Disable the snapping effect + physics: const ClampingSheetPhysics(), + child: Container( + key: targetKey, + color: Colors.white, + width: double.infinity, + height: 500, + ), + ), + ), + ); + + final gesturePointer = await tester.press(find.byKey(targetKey)); + await gesturePointer.moveBy(const Offset(0, 20)); + await tester.pump(); + expect(reportedNotifications, hasLength(2)); + expect( + reportedNotifications[0], + isA().having( + (e) => e.dragDetails.axisDirection, + 'axisDirection', + // Since the y-axis is upward and we are performing a downward drag, + // the sign of the overflowed delta should be negative. + VerticalDirection.up, + ), + ); + expect( + reportedNotifications[1], + isA() + .having((e) => e.metrics.pixels, 'pixels', 500) + .having((e) => e.status, 'status', SheetStatus.dragging) + .having((e) => e.overflow, 'overflow', -20), + ); + + reportedNotifications.clear(); + await gesturePointer.moveBy(const Offset(0, 20)); + await tester.pump(); + expect( + reportedNotifications.single, + isA() + .having((e) => e.metrics.pixels, 'pixels', 500) + .having((e) => e.status, 'status', SheetStatus.dragging) + .having((e) => e.overflow, 'overflow', -20), + ); + + reportedNotifications.clear(); + await gesturePointer.up(); + await tester.pump(); + expect(reportedNotifications.single, isA()); + + reportedNotifications.clear(); + await tester.pumpAndSettle(); + expect(reportedNotifications, isEmpty, + reason: 'Once the drag is ended, ' + 'no notification should be dispatched.'); + }, + ); +} diff --git a/package/test/scrollable/scrollable_sheet_test.dart b/package/test/scrollable/scrollable_sheet_test.dart index d6084eec..3c47b625 100644 --- a/package/test/scrollable/scrollable_sheet_test.dart +++ b/package/test/scrollable/scrollable_sheet_test.dart @@ -38,12 +38,16 @@ class _TestSheetContent extends StatelessWidget { const _TestSheetContent({ super.key, this.height = 500, + this.itemCount = 30, // Disable the snapping effect by default in tests. this.scrollPhysics = const ClampingScrollPhysics(), + this.onTapItem, }); final double? height; + final int itemCount; final ScrollPhysics? scrollPhysics; + final void Function(int index)? onTapItem; @override Widget build(BuildContext context) { @@ -54,9 +58,10 @@ class _TestSheetContent extends StatelessWidget { child: ListView( physics: scrollPhysics, children: List.generate( - 30, + itemCount, (index) => ListTile( title: Text('Item $index'), + onTap: onTapItem != null ? () => onTapItem!(index) : null, ), ), ), @@ -101,11 +106,7 @@ void main() { controller: controller, minExtent: const Extent.pixels(200), initialExtent: const Extent.pixels(200), - child: const Material( - child: _TestSheetContent( - height: 500, - ), - ), + child: const _TestSheetContent(height: 500), ), ), ), @@ -139,6 +140,66 @@ void main() { ); }); + // Regression test for https://github.com/fujidaiti/smooth_sheets/issues/190 + testWidgets( + 'Press and hold scrollable view should stop momentum scrolling', + (tester) async { + const targetKey = Key('Target'); + final controller = SheetController(); + late ScrollController scrollController; + + await tester.pumpWidget( + _TestApp( + child: ScrollableSheet( + controller: controller, + child: Builder( + builder: (context) { + // TODO(fujita): Refactor this line after #116 is resolved. + scrollController = PrimaryScrollController.of(context); + return _TestSheetContent( + key: targetKey, + itemCount: 1000, + height: null, + // The items need to be clickable to cause the reported issue. + onTapItem: (index) {}, + ); + }, + ), + ), + ), + ); + + const dragDistance = 200.0; + const flingSpeed = 2000.0; + await tester.fling( + find.byKey(targetKey), + const Offset(0, -1 * dragDistance), // Fling up + flingSpeed, + ); + + final offsetAfterFling = scrollController.offset; + // Don't know why, but we need to call `pump` at least 2 times + // to forward the animation clock. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + final offsetBeforePress = scrollController.offset; + expect(offsetBeforePress, greaterThan(offsetAfterFling), + reason: 'Momentum scrolling should be in progress.'); + + // Press and hold the finger on the target widget. + await tester.press(find.byKey(targetKey)); + // Wait for the momentum scrolling to stop. + await tester.pumpAndSettle(); + final offsetAfterPress = scrollController.offset; + expect( + offsetAfterPress, + equals(offsetBeforePress), + reason: 'Momentum scrolling should be stopped immediately' + 'by pressing and holding.', + ); + }, + ); + group('SheetKeyboardDismissible', () { late FocusNode focusNode; late Widget testWidget;