From 3fa8352bf99e39daf4682a162cd35c39d5622d0d Mon Sep 17 00:00:00 2001 From: Daichi Fujita <68946713+fujidaiti@users.noreply.github.com> Date: Fri, 23 Feb 2024 01:30:18 +0900 Subject: [PATCH] Physics improvements (#32) Closes #20, closes #29. ### New Features - Added `SnapToNearestEdge`, a `SheetSnappingBehavior` that snaps to the nearest edge either `minPixels` or `maxPixels`. - Added convenient getters to `MaybeSheetMetrics`, `isPixelsInBounds` and `isPixelsOutOfBounds`. - Added `ScrollableSheetPhysics` as the replacement for the deleted `SnapToNearest.maxFlingVelocityToSnap` property. - Made `SnapToNearest` aware of fling gestures. ### Breaking Changes - Deleted `SnapToNearest.maxFlingVelocityToSnap`. - `SnapToNearest` can no longer be a `const`, in exchange for performance improvement. Due to the above reason, `SnapToNearestEdge` has been used as the default value for `SnappingSheetPhysics.snappingBehavior` instead of `SnapToNearest`. --- cookbook/ios/Runner.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- .../lib/showcase/ai_playlist_generator.dart | 9 +- cookbook/lib/showcase/airbnb_mobile_app.dart | 4 - cookbook/lib/showcase/safari/menu.dart | 6 +- .../lib/tutorial/cupertino_modal_sheet.dart | 9 +- cookbook/lib/tutorial/scrollable_sheet.dart | 14 + .../lib/tutorial/sheet_content_scaffold.dart | 8 +- cookbook/lib/tutorial/sheet_controller.dart | 9 +- cookbook/lib/tutorial/sheet_draggable.dart | 9 +- cookbook/lib/tutorial/sheet_physics.dart | 13 +- .../lib/src/draggable/sheet_draggable.dart | 3 +- .../lib/src/foundation/sheet_activity.dart | 2 +- package/lib/src/foundation/sheet_extent.dart | 32 +- package/lib/src/foundation/sheet_physics.dart | 342 ++++++++++-------- package/lib/src/internal/double_utils.dart | 4 +- .../scrollable/scrollable_sheet_extent.dart | 41 ++- .../scrollable/scrollable_sheet_physics.dart | 22 ++ .../test/foundation/sheet_physics_test.dart | 181 +++++++++ 19 files changed, 492 insertions(+), 220 deletions(-) create mode 100644 package/lib/src/scrollable/scrollable_sheet_physics.dart create mode 100644 package/test/foundation/sheet_physics_test.dart diff --git a/cookbook/ios/Runner.xcodeproj/project.pbxproj b/cookbook/ios/Runner.xcodeproj/project.pbxproj index ed332670..df303932 100644 --- a/cookbook/ios/Runner.xcodeproj/project.pbxproj +++ b/cookbook/ios/Runner.xcodeproj/project.pbxproj @@ -169,7 +169,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 331C8080294A63A400263BE5 = { diff --git a/cookbook/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/cookbook/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 87131a09..8e3ca5df 100644 --- a/cookbook/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/cookbook/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ const StretchingSheetPhysics(), _PhysicsKind.clampingSnapping => // Use 'parent' to combine multiple physics behaviors. - const ClampingSheetPhysics(parent: snappingPhysics), + ClampingSheetPhysics(parent: snappingPhysics), _PhysicsKind.stretchingSnapping => - const StretchingSheetPhysics(parent: snappingPhysics), + StretchingSheetPhysics(parent: snappingPhysics), }; } diff --git a/package/lib/src/draggable/sheet_draggable.dart b/package/lib/src/draggable/sheet_draggable.dart index 00cfd6f0..ceeba243 100644 --- a/package/lib/src/draggable/sheet_draggable.dart +++ b/package/lib/src/draggable/sheet_draggable.dart @@ -84,8 +84,7 @@ class UserDragSheetActivity extends SheetActivity void onDragEnd(DragEndDetails details) { if (!mounted) return; - // TODO: Support fling gestures - delegate.goBallistic(0); + delegate.goBallistic(-1 * details.velocity.pixelsPerSecond.dy); } void onDragCancel() { diff --git a/package/lib/src/foundation/sheet_activity.dart b/package/lib/src/foundation/sheet_activity.dart index 9bd5926f..34c813a3 100644 --- a/package/lib/src/foundation/sheet_activity.dart +++ b/package/lib/src/foundation/sheet_activity.dart @@ -197,7 +197,7 @@ class BallisticSheetActivity extends SheetActivity @override void onAnimationEnd() { - delegate.settle(); + delegate.goBallistic(0); } } diff --git a/package/lib/src/foundation/sheet_extent.dart b/package/lib/src/foundation/sheet_extent.dart index a08fa2cb..97fb3bd7 100644 --- a/package/lib/src/foundation/sheet_extent.dart +++ b/package/lib/src/foundation/sheet_extent.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:smooth_sheets/src/foundation/sheet_activity.dart'; import 'package:smooth_sheets/src/foundation/sheet_controller.dart'; import 'package:smooth_sheets/src/foundation/sheet_physics.dart'; +import 'package:smooth_sheets/src/internal/double_utils.dart'; /// Visible area of the sheet. abstract interface class Extent { @@ -217,17 +218,23 @@ abstract class SheetExtent with ChangeNotifier, MaybeSheetMetrics { assert(hasPixels); final simulation = physics.createBallisticSimulation(velocity, metrics); if (simulation != null) { - beginActivity(BallisticSheetActivity(simulation: simulation)); + goBallisticWith(simulation); } else { goIdle(); } } + void goBallisticWith(Simulation simulation) { + assert(hasPixels); + beginActivity(BallisticSheetActivity(simulation: simulation)); + } + void settle() { assert(hasPixels); final simulation = physics.createSettlingSimulation(metrics); if (simulation != null) { - beginActivity(BallisticSheetActivity(simulation: simulation)); + // TODO: Begin a SettlingSheetActivity + goBallisticWith(simulation); } else { goIdle(); } @@ -325,6 +332,11 @@ mixin MaybeSheetMetrics { contentDimensions != null && viewportDimensions != null; + bool get isPixelsInBounds => + hasPixels && pixels!.isInBounds(minPixels!, maxPixels!); + + bool get isPixelsOutOfBounds => !isPixelsInBounds; + @override String toString() => ( hasPixels: hasPixels, @@ -402,6 +414,22 @@ class SheetMetricsSnapshot with MaybeSheetMetrics, SheetMetrics { @override bool get hasPixels => true; + SheetMetricsSnapshot copyWith({ + double? pixels, + double? minPixels, + double? maxPixels, + Size? contentDimensions, + ViewportDimensions? viewportDimensions, + }) { + return SheetMetricsSnapshot( + pixels: pixels ?? this.pixels, + minPixels: minPixels ?? this.minPixels, + maxPixels: maxPixels ?? this.maxPixels, + contentDimensions: contentDimensions ?? this.contentDimensions, + viewportDimensions: viewportDimensions ?? this.viewportDimensions, + ); + } + @override bool operator ==(Object other) { if (identical(this, other)) return true; diff --git a/package/lib/src/foundation/sheet_physics.dart b/package/lib/src/foundation/sheet_physics.dart index 9baa5bb4..83718383 100644 --- a/package/lib/src/foundation/sheet_physics.dart +++ b/package/lib/src/foundation/sheet_physics.dart @@ -1,12 +1,14 @@ import 'dart:math'; +import 'dart:ui'; import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; import 'package:smooth_sheets/src/foundation/sheet_extent.dart'; import 'package:smooth_sheets/src/internal/double_utils.dart'; -// logical pixels per second -const _defaultSettlingSpeed = 1000.0; +const _minSettlingDuration = Duration(milliseconds: 160); +const _defaultSettlingSpeed = 600.0; // logical pixels per second abstract class SheetPhysics { const SheetPhysics({ @@ -22,8 +24,8 @@ abstract class SheetPhysics { } double computeOverflow(double offset, SheetMetrics metrics) { - if (parent != null) { - return parent!.computeOverflow(offset, metrics); + if (parent case final parent?) { + return parent.computeOverflow(offset, metrics); } final newPixels = metrics.pixels + offset; @@ -37,8 +39,8 @@ abstract class SheetPhysics { } double applyPhysicsToOffset(double offset, SheetMetrics metrics) { - if (parent != null) { - return parent!.applyPhysicsToOffset(offset, metrics); + if (parent case final parent?) { + return parent.applyPhysicsToOffset(offset, metrics); } else if (offset > 0 && metrics.pixels < metrics.maxPixels) { // Prevent the pixels from going beyond the maximum value. return min(metrics.maxPixels, metrics.pixels + offset) - metrics.pixels; @@ -51,45 +53,46 @@ abstract class SheetPhysics { } Simulation? createBallisticSimulation(double velocity, SheetMetrics metrics) { - if (parent != null) { - return parent!.createBallisticSimulation(velocity, metrics); - } else if (metrics.pixels.isLessThan(metrics.minPixels)) { - return ScrollSpringSimulation( - spring, metrics.pixels, metrics.minPixels, velocity); - } else if (metrics.pixels.isGreaterThan(metrics.maxPixels)) { - return ScrollSpringSimulation( - spring, metrics.pixels, metrics.maxPixels, velocity); - } else { + if (parent case final parent?) { + return parent.createBallisticSimulation(velocity, metrics); + } else if (metrics.isPixelsInBounds) { return null; } + + final destination = + metrics.pixels.nearest(metrics.minPixels, metrics.maxPixels); + final direction = (destination - metrics.pixels).sign; + + return ScrollSpringSimulation( + spring, + metrics.pixels, + destination, + // The simulation velocity is intentionally set to 0 if the velocity is + // is in the opposite direction of the destination, as flinging up an + // over-dragged sheet or flinging down an under-dragged sheet tends to + // cause unstable motion. + velocity.sign == direction ? velocity : 0.0, + ); } Simulation? createSettlingSimulation(SheetMetrics metrics) { - if (parent != null) { - return parent!.createSettlingSimulation(metrics); - } else if (metrics.pixels.isLessThan(metrics.minPixels)) { - return UniformLinearSimulation( - position: metrics.pixels, - detent: metrics.minPixels, - speed: _defaultSettlingSpeed, - ); - } else if (metrics.pixels.isGreaterThan(metrics.maxPixels)) { - return UniformLinearSimulation( - position: metrics.pixels, - detent: metrics.minPixels, - speed: _defaultSettlingSpeed, - ); - } else { + if (parent case final parent?) { + return parent.createSettlingSimulation(metrics); + } else if (metrics.isPixelsInBounds) { return null; } - } - - bool shouldGoBallistic(double velocity, SheetMetrics metrics) { - if (parent != null) { - return parent!.shouldGoBallistic(velocity, metrics); - } - - return metrics.pixels.isOutOfRange(metrics.minPixels, metrics.maxPixels); + final settleTo = + metrics.pixels.nearest(metrics.minPixels, metrics.maxPixels); + + return _InterpolationSimulation( + start: metrics.pixels, + end: settleTo, + curve: Curves.easeInOut, + durationInSeconds: max( + (metrics.pixels - settleTo).abs() / _defaultSettlingSpeed, + _minSettlingDuration.inMicroseconds / Duration.microsecondsPerSecond, + ), + ); } @override @@ -103,97 +106,171 @@ abstract class SheetPhysics { int get hashCode => Object.hash(runtimeType, parent); } -class UniformLinearSimulation extends Simulation { - UniformLinearSimulation({ - required this.position, - required this.detent, - required double speed, - }) : assert(speed > 0) { - velocity = (detent - position).sign * speed; - duration = (detent - position) / velocity; - } +class _InterpolationSimulation extends Simulation { + _InterpolationSimulation({ + required this.start, + required this.end, + required this.curve, + required this.durationInSeconds, + }) : assert(start != end), + assert(durationInSeconds > 0); - final double position; - final double detent; - late final double velocity; - late final double duration; + final double start; + final double end; + final Curve curve; + late final double durationInSeconds; @override double dx(double time) { - return velocity; + final epsilon = tolerance.time; + return (x(time + epsilon) - x(time - epsilon)) / (2 * epsilon); } @override double x(double time) { - return switch (time < duration) { - true => position + velocity * time, - false => detent, - }; + final t = curve.transform((time / durationInSeconds).clamp(0, 1)); + return lerpDouble(start, end, t)!; } @override bool isDone(double time) { - return x(time).isApprox(detent); + return x(time).isApprox(end); } } -typedef SnapPixelsProvider = double? Function( - Iterable snapTo, - double velocity, - SheetMetrics metrics, -); - abstract interface class SnappingSheetBehavior { - double? findSnapPixels(double scrollVelocity, SheetMetrics metrics); + double? findSnapPixels(double velocity, SheetMetrics metrics); } -class SnapToNearest implements SnappingSheetBehavior { - const SnapToNearest({ - this.snapTo = const [Extent.proportional(1)], - this.maxFlingVelocityToSnap = 700, - }) : assert(maxFlingVelocityToSnap >= 0); +mixin _SnapToNearestMixin implements SnappingSheetBehavior { + /// The lowest speed (in logical pixels per second) + /// at which a gesture is considered to be a fling. + double get minFlingSpeed; - final List snapTo; - final double maxFlingVelocityToSnap; - - // TODO: Cache the result in a Expando. - ({double min, double max}) _getSnapRange(SheetMetrics metrics) { - var minPixels = double.infinity; - var maxPixels = double.negativeInfinity; - for (final snapExtent in snapTo) { - final snapPixels = snapExtent.resolve(metrics.contentDimensions); - minPixels = min(snapPixels, minPixels); - maxPixels = max(snapPixels, maxPixels); + @protected + (double, double) _getSnapBoundsContains(SheetMetrics metrics); + + @override + double? findSnapPixels(double velocity, SheetMetrics metrics) { + assert(minFlingSpeed >= 0); + + if (metrics.pixels.isOutOfBounds(metrics.minPixels, metrics.maxPixels)) { + return null; } - return (min: minPixels, max: maxPixels); + final (nearestSmaller, nearestGreater) = _getSnapBoundsContains(metrics); + if (velocity.abs() < minFlingSpeed) { + return metrics.pixels.nearest(nearestSmaller, nearestGreater); + } else if (velocity < 0) { + return nearestSmaller; + } else { + return nearestGreater; + } } +} + +/// A [SnappingSheetBehavior] that snaps to either [SheetExtent.minPixels] +/// or [SheetExtent.maxPixels] based on the current sheet position and +/// the gesture velocity. +/// +/// If the absolute value of the gesture velocity is less than +/// [minFlingSpeed], the sheet will snap to the nearest of +/// [SheetExtent.minPixels] and [SheetExtent.maxPixels]. +/// Otherwise, the gesture is considered to be a fling, and the sheet will snap +/// towards the direction of the fling. For example, if the sheet is flung up, +/// it will snap to [SheetExtent.maxPixels]. +/// +/// Using this behavior is functionally identical to using [SnapToNearest] +/// with the snap positions of [SheetExtent.minExtent] and +/// [SheetExtent.maxExtent], but more simplified and efficient. +class SnapToNearestEdge with _SnapToNearestMixin { + /// Creates a [SnappingSheetBehavior] that snaps to either + /// [SheetExtent.minPixels] or [SheetExtent.maxPixels]. + /// + /// The [minFlingSpeed] defaults to [kMinFlingVelocity], + /// and must be non-negative. + const SnapToNearestEdge({ + this.minFlingSpeed = kMinFlingVelocity, + }) : assert(minFlingSpeed >= 0); @override - double? findSnapPixels(double scrollVelocity, SheetMetrics metrics) { - if (_shouldSnap(scrollVelocity, metrics)) { - return _findNearestPixelsIn(snapTo, metrics); - } else { - return null; - } + final double minFlingSpeed; + + @override + (double, double) _getSnapBoundsContains(SheetMetrics metrics) { + assert(metrics.pixels.isInBounds(metrics.minPixels, metrics.maxPixels)); + return (metrics.minPixels, metrics.maxPixels); } - double _findNearestPixelsIn(List snapTo, SheetMetrics metrics) { - assert(snapTo.isNotEmpty); - // TODO: Cache the snap positions in a Expando. - return snapTo - .map((extent) => extent.resolve(metrics.contentDimensions)) - .reduce((nearest, next) => metrics.pixels.nearest(nearest, next)); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SnapToNearestEdge && + runtimeType == other.runtimeType && + minFlingSpeed == other.minFlingSpeed); + + @override + int get hashCode => Object.hash( + runtimeType, + minFlingSpeed, + ); +} + +class SnapToNearest with _SnapToNearestMixin { + SnapToNearest({ + required this.snapTo, + this.minFlingSpeed = kMinFlingVelocity, + }) : assert(snapTo.isNotEmpty), + assert(minFlingSpeed >= 0); + + final List snapTo; + + @override + final double minFlingSpeed; + + /// Cached results of [Extent.resolve] for each snap position in [snapTo]. + /// + /// Always call [_ensureCacheIsValid] before accessing this list + /// to ensure that the cache is up-to-date and sorted in ascending order. + List _snapTo = const []; + Size? _cachedContentDimensions; + + void _ensureCacheIsValid(SheetMetrics metrics) { + if (_cachedContentDimensions != metrics.contentDimensions) { + _cachedContentDimensions = metrics.contentDimensions; + _snapTo = snapTo + .map((e) => e.resolve(metrics.contentDimensions)) + .toList(growable: false) + ..sort(); + + assert( + _snapTo.first.isGreaterThanOrApprox(metrics.minPixels) && + _snapTo.last.isLessThanOrApprox(metrics.maxPixels), + 'The snap positions must be within the range of ' + "'SheetExtent.minPixels' and 'SheetExtent.maxPixels'.", + ); + } } - bool _shouldSnap(double scrollVelocity, SheetMetrics metrics) { - final velocityIsLowEnough = scrollVelocity.abs() < maxFlingVelocityToSnap; - final snapRange = _getSnapRange(metrics); - final currentExtentIsAtOutOfSnapRange = - metrics.pixels < snapRange.min || metrics.pixels > snapRange.max; + @override + (double, double) _getSnapBoundsContains(SheetMetrics metrics) { + _ensureCacheIsValid(metrics); + if (_snapTo.length == 1) { + return (_snapTo.first, _snapTo.first); + } + + var nearestSmaller = _snapTo[0]; + var nearestGreater = _snapTo[1]; + for (var index = 0; index < _snapTo.length - 1; index++) { + if (_snapTo[index].isLessThan(metrics.pixels)) { + nearestSmaller = _snapTo[index]; + nearestGreater = _snapTo[index + 1]; + } else { + break; + } + } - return (velocityIsLowEnough || currentExtentIsAtOutOfSnapRange) && - snapTo.isNotEmpty; + return (nearestSmaller, nearestGreater); } @override @@ -201,13 +278,13 @@ class SnapToNearest implements SnappingSheetBehavior { identical(this, other) || (other is SnapToNearest && runtimeType == other.runtimeType && - maxFlingVelocityToSnap == other.maxFlingVelocityToSnap && + minFlingSpeed == other.minFlingSpeed && const DeepCollectionEquality().equals(snapTo, other.snapTo)); @override int get hashCode => Object.hash( runtimeType, - maxFlingVelocityToSnap, + minFlingSpeed, snapTo, ); } @@ -216,24 +293,11 @@ class SnappingSheetPhysics extends SheetPhysics { const SnappingSheetPhysics({ super.parent, super.spring, - this.snappingBehavior = const SnapToNearest(), + this.snappingBehavior = const SnapToNearestEdge(), }); final SnappingSheetBehavior snappingBehavior; - @override - bool shouldGoBallistic(double velocity, SheetMetrics metrics) { - // TODO: Support flinging gestures. - final snapPixels = snappingBehavior.findSnapPixels(velocity, metrics); - final currentPixels = metrics.pixels; - - if (snapPixels != null && !currentPixels.isApprox(snapPixels)) { - return true; - } else { - return super.shouldGoBallistic(velocity, metrics); - } - } - @override Simulation? createBallisticSimulation(double velocity, SheetMetrics metrics) { final snapPixels = snappingBehavior.findSnapPixels(velocity, metrics); @@ -245,20 +309,6 @@ class SnappingSheetPhysics extends SheetPhysics { } } - @override - Simulation? createSettlingSimulation(SheetMetrics metrics) { - final snapPixels = snappingBehavior.findSnapPixels(0, metrics); - if (snapPixels != null && !metrics.pixels.isApprox(snapPixels)) { - return UniformLinearSimulation( - position: metrics.pixels, - detent: snapPixels, - speed: _defaultSettlingSpeed, - ); - } else { - return super.createSettlingSimulation(metrics); - } - } - @override bool operator ==(Object other) => identical(this, other) || @@ -298,26 +348,13 @@ class StretchingSheetPhysics extends SheetPhysics { return super.computeOverflow(offset, metrics); } - @override - Simulation? createBallisticSimulation(double velocity, SheetMetrics metrics) { - if ((metrics.pixels.isGreaterThan(metrics.maxPixels) && velocity > 0) || - (metrics.pixels.isLessThan(metrics.minPixels) && velocity < 0)) { - // Limit the velocity to prevent the sheet from being flung too far. - const maxFlingVelocity = 100.0; - final clampedVelocity = velocity.clampAbs(maxFlingVelocity); - return super.createBallisticSimulation(clampedVelocity, metrics); - } - - return super.createBallisticSimulation(velocity, metrics); - } - @override double applyPhysicsToOffset(double offset, SheetMetrics metrics) { final currentPixels = metrics.pixels; final minPixels = metrics.minPixels; final maxPixels = metrics.maxPixels; - if (currentPixels.isInRange(minPixels, maxPixels) || + if (currentPixels.isInBounds(minPixels, maxPixels) || (currentPixels > maxPixels && offset < 0) || (currentPixels < minPixels && offset > 0)) { // The friction is not applied if the current 'pixels' is within the range @@ -354,4 +391,19 @@ class StretchingSheetPhysics extends SheetPhysics { return newPixels - currentPixels; } + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StretchingSheetPhysics && + stretchingRange == other.stretchingRange && + frictionCurve == other.frictionCurve && + super == other); + + @override + int get hashCode => Object.hash( + stretchingRange, + frictionCurve, + super.hashCode, + ); } diff --git a/package/lib/src/internal/double_utils.dart b/package/lib/src/internal/double_utils.dart index f388d31c..7763246c 100644 --- a/package/lib/src/internal/double_utils.dart +++ b/package/lib/src/internal/double_utils.dart @@ -15,10 +15,10 @@ extension DoubleUtils on double { bool isGreaterThanOrApprox(double value) => isGreaterThan(value) || isApprox(value); - bool isOutOfRange(double min, double max) => + bool isOutOfBounds(double min, double max) => isLessThan(min) || isGreaterThan(max); - bool isInRange(double min, double max) => !isOutOfRange(min, max); + bool isInBounds(double min, double max) => !isOutOfBounds(min, max); double clampAbs(double norm) => min(max(-norm, this), norm); diff --git a/package/lib/src/scrollable/scrollable_sheet_extent.dart b/package/lib/src/scrollable/scrollable_sheet_extent.dart index 50aa24d6..b54e4a29 100644 --- a/package/lib/src/scrollable/scrollable_sheet_extent.dart +++ b/package/lib/src/scrollable/scrollable_sheet_extent.dart @@ -3,10 +3,12 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:smooth_sheets/src/foundation/sheet_activity.dart'; import 'package:smooth_sheets/src/foundation/sheet_extent.dart'; +import 'package:smooth_sheets/src/foundation/sheet_physics.dart'; import 'package:smooth_sheets/src/foundation/single_child_sheet.dart'; import 'package:smooth_sheets/src/internal/double_utils.dart'; import 'package:smooth_sheets/src/internal/into.dart'; import 'package:smooth_sheets/src/scrollable/content_scroll_position.dart'; +import 'package:smooth_sheets/src/scrollable/scrollable_sheet_physics.dart'; class ScrollableSheetExtentFactory extends SingleChildSheetExtentFactory { const ScrollableSheetExtentFactory({ @@ -33,9 +35,13 @@ class ScrollableSheetExtent extends SingleChildSheetExtent { required super.initialExtent, required super.minExtent, required super.maxExtent, - required super.physics, required super.context, - }) { + required SheetPhysics physics, + }) : super( + physics: physics is ScrollableSheetPhysics + ? physics + : ScrollableSheetPhysics(parent: physics), + ) { goIdle(); } @@ -63,15 +69,10 @@ class ScrollableSheetExtent extends SingleChildSheetExtent { ); @override - void goBallistic(double velocity) { - final simulation = physics.createBallisticSimulation(velocity, metrics); - if (simulation != null) { - beginActivity( - _DragInterruptibleBallisticSheetActivity(simulation: simulation), - ); - } else { - goIdle(); - } + void goBallisticWith(Simulation simulation) { + beginActivity( + _DragInterruptibleBallisticSheetActivity(simulation: simulation), + ); } @override @@ -205,9 +206,13 @@ sealed class _ContentScrollDrivenSheetActivity extends SheetActivity return const DelegationResult.notHandled(); } - if (delegate.physics.shouldGoBallistic(velocity, delegate.metrics)) { - delegate.goBallistic(velocity); - return DelegationResult.handled(IdleScrollActivity(position)); + if (position.pixels.isApprox(position.minScrollExtent)) { + final simulation = delegate.physics + .createBallisticSimulation(velocity, delegate.metrics); + if (simulation != null) { + delegate.goBallisticWith(simulation); + return DelegationResult.handled(IdleScrollActivity(position)); + } } final scrollSimulation = position.physics.createBallisticSimulation( @@ -300,8 +305,12 @@ class _ContentBallisticScrollDrivenSheetActivity dispatchUpdateNotification(); } - if (delegate.physics.shouldGoBallistic(velocity, delegate.metrics)) { - delegate.goBallistic(velocity); + final physics = delegate.physics; + if (((position.extentBefore.isApprox(0) && velocity < 0) || + (position.extentAfter.isApprox(0) && velocity > 0)) && + physics is ScrollableSheetPhysics && + physics.shouldInterruptBallisticScroll(velocity, delegate.metrics)) { + delegate.goBallistic(0); } return DelegationResult.handled(overscroll); diff --git a/package/lib/src/scrollable/scrollable_sheet_physics.dart b/package/lib/src/scrollable/scrollable_sheet_physics.dart new file mode 100644 index 00000000..7936865e --- /dev/null +++ b/package/lib/src/scrollable/scrollable_sheet_physics.dart @@ -0,0 +1,22 @@ +import 'package:smooth_sheets/src/foundation/sheet_extent.dart'; +import 'package:smooth_sheets/src/foundation/sheet_physics.dart'; + +mixin ScrollableSheetPhysicsMixin on SheetPhysics { + bool shouldInterruptBallisticScroll(double velocity, SheetMetrics metrics); +} + +class ScrollableSheetPhysics extends SheetPhysics + with ScrollableSheetPhysicsMixin { + const ScrollableSheetPhysics({ + super.parent, + super.spring, + this.maxScrollSpeedToInterrupt = double.infinity, + }) : assert(maxScrollSpeedToInterrupt >= 0); + + final double maxScrollSpeedToInterrupt; + + @override + bool shouldInterruptBallisticScroll(double velocity, SheetMetrics metrics) { + return velocity.abs() < maxScrollSpeedToInterrupt; + } +} diff --git a/package/test/foundation/sheet_physics_test.dart b/package/test/foundation/sheet_physics_test.dart new file mode 100644 index 00000000..3aa560b7 --- /dev/null +++ b/package/test/foundation/sheet_physics_test.dart @@ -0,0 +1,181 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; + +class _SheetPhysicsWithDefaultConfiguration extends SheetPhysics { + const _SheetPhysicsWithDefaultConfiguration(); +} + +const _referenceSheetMetrics = SheetMetricsSnapshot( + minPixels: 0, + maxPixels: 600, + pixels: 600, + contentDimensions: Size(360, 600), + viewportDimensions: ViewportDimensions( + width: 360, + height: 700, + insets: EdgeInsets.zero, + ), +); + +final _positionAtTopEdge = + _referenceSheetMetrics.copyWith(pixels: _referenceSheetMetrics.maxPixels); + +final _positionAtBottomEdge = + _referenceSheetMetrics.copyWith(pixels: _referenceSheetMetrics.minPixels); + +final _positionAtMiddle = _referenceSheetMetrics.copyWith( + pixels: (_positionAtTopEdge.pixels + _positionAtBottomEdge.pixels) / 2, +); + +void main() { + group('Default configuration of $SheetPhysics', () { + late SheetPhysics physicsUnderTest; + + setUp(() { + physicsUnderTest = const _SheetPhysicsWithDefaultConfiguration(); + }); + + test('does not allow over/under dragging', () { + expect( + physicsUnderTest.computeOverflow(10, _positionAtTopEdge), + moreOrLessEquals(10), + ); + expect( + physicsUnderTest.computeOverflow(-10, _positionAtBottomEdge), + moreOrLessEquals(-10), + ); + }); + + test('does not apply any resistance if the position is in bounds', () { + final positionAtNearTopEdge = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.maxPixels - 10); + final positionAtNearBottomEdge = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.minPixels + 10); + + expect( + physicsUnderTest.applyPhysicsToOffset(10, _positionAtMiddle), + moreOrLessEquals(10), + ); + expect( + physicsUnderTest.applyPhysicsToOffset(10, positionAtNearTopEdge), + moreOrLessEquals(10), + ); + expect( + physicsUnderTest.applyPhysicsToOffset(-10, positionAtNearBottomEdge), + moreOrLessEquals(-10), + ); + }); + + test('prevents position from going out of bounds', () { + expect( + physicsUnderTest.applyPhysicsToOffset(10, _positionAtTopEdge), + moreOrLessEquals(0), + ); + expect( + physicsUnderTest.applyPhysicsToOffset(-10, _positionAtBottomEdge), + moreOrLessEquals(0), + ); + }); + + test('creates no ballistic simulation if the position is in bounds', () { + expect( + physicsUnderTest.createBallisticSimulation(0, _positionAtMiddle), + isNull, + ); + expect( + physicsUnderTest.createBallisticSimulation(0, _positionAtTopEdge), + isNull, + ); + expect( + physicsUnderTest.createBallisticSimulation(0, _positionAtBottomEdge), + isNull, + ); + }); + + test('creates ballistic simulation which ends at the nearest edge', () { + final overDraggedPosition = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.maxPixels + 10, + ); + final underDragPosition = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.minPixels - 10, + ); + final overDragSimulation = + physicsUnderTest.createBallisticSimulation(0, overDraggedPosition); + final underDraggedSimulation = + physicsUnderTest.createBallisticSimulation(0, underDragPosition); + + expect(overDragSimulation, isNotNull); + expect( + overDragSimulation!.x(5), // 5s passed + moreOrLessEquals(_referenceSheetMetrics.maxPixels), + ); + expect( + overDragSimulation.dx(5), // 5s passed + moreOrLessEquals(0), + ); + + expect(underDraggedSimulation, isNotNull); + expect( + underDraggedSimulation!.x(5), // 5s passed + moreOrLessEquals(_referenceSheetMetrics.minPixels), + ); + expect( + underDraggedSimulation.dx(5), // 5s passed + moreOrLessEquals(0), + ); + }); + + test('creates no settling simulation if the position is in bounds', () { + expect( + physicsUnderTest.createSettlingSimulation(_positionAtMiddle), + isNull, + ); + expect( + physicsUnderTest.createSettlingSimulation(_positionAtTopEdge), + isNull, + ); + expect( + physicsUnderTest.createSettlingSimulation(_positionAtBottomEdge), + isNull, + ); + }); + + test('creates settling simulation which ends at the nearest edge', () { + final moreOverDraggedPosition = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.maxPixels + 200, + ); + final lessOverDraggedPosition = _referenceSheetMetrics.copyWith( + pixels: _referenceSheetMetrics.maxPixels + 10, + ); + final moreOverDragSimulation = + physicsUnderTest.createSettlingSimulation(moreOverDraggedPosition); + final lessOverDragSimulation = + physicsUnderTest.createSettlingSimulation(lessOverDraggedPosition); + + // The settling simulation runs with the average velocity of 600px/s + // if the starting position is far enough from the edge. + expect(moreOverDragSimulation, isNotNull); + expect( + moreOverDragSimulation!.x(0.170), // 170ms passed + greaterThan(_referenceSheetMetrics.maxPixels), + ); + expect( + moreOverDragSimulation.x(0.334), // 334ms passed (≈ 200px / 600px/s) + moreOrLessEquals(_referenceSheetMetrics.maxPixels), + ); + + // The default behavior ensures that the settling simulation runs for + // at least 160ms even if the starting position is too close to the edge. + expect(lessOverDragSimulation, isNotNull); + expect( + lessOverDragSimulation!.x(0.08), // 80ms passed + greaterThan(_referenceSheetMetrics.maxPixels), + ); + expect( + lessOverDragSimulation.x(0.16), // 160ms passed + moreOrLessEquals(_referenceSheetMetrics.maxPixels), + ); + }); + }); +}