diff --git a/package/lib/src/foundation/sheet_context.dart b/package/lib/src/foundation/sheet_context.dart index 7f209a23..b42fb4c6 100644 --- a/package/lib/src/foundation/sheet_context.dart +++ b/package/lib/src/foundation/sheet_context.dart @@ -8,6 +8,7 @@ import 'sheet_extent.dart'; abstract class SheetContext { TickerProvider get vsync; BuildContext? get notificationContext; + double get devicePixelRatio; } @internal @@ -20,4 +21,8 @@ mixin SheetContextStateMixin @override BuildContext? get notificationContext => mounted ? context : null; + + @override + double get devicePixelRatio => + MediaQuery.maybeDevicePixelRatioOf(context) ?? 1.0; } diff --git a/package/lib/src/foundation/sheet_extent.dart b/package/lib/src/foundation/sheet_extent.dart index 55e6d249..e26255b2 100644 --- a/package/lib/src/foundation/sheet_extent.dart +++ b/package/lib/src/foundation/sheet_extent.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; -import '../internal/double_utils.dart'; +import '../internal/float_comp.dart'; import 'sheet_activity.dart'; import 'sheet_context.dart'; import 'sheet_controller.dart'; @@ -175,10 +175,6 @@ abstract class SheetExtent extends ChangeNotifier /// A label that is used to identify this object in debug output. final String? debugLabel; - /// Snapshot of the current sheet's state. - SheetMetrics get metrics => _metrics; - SheetMetrics _metrics = SheetMetrics.empty; - /// The current activity of the sheet. SheetActivity get activity => _activity!; SheetActivity? _activity; @@ -190,6 +186,34 @@ abstract class SheetExtent extends ChangeNotifier @protected SheetDragController? currentDrag; + /// Snapshot of the current sheet's state. + SheetMetrics get metrics => _metrics; + SheetMetrics _metrics = SheetMetrics.empty; + + /// Updates the metrics with the given values. + /// + /// Use this method instead of directly updating the metrics + /// to ensure that the [SheetMetrics.devicePixelRatio] is always up-to-date. + void _updateMetrics({ + double? pixels, + double? minPixels, + double? maxPixels, + Size? contentSize, + Size? viewportSize, + EdgeInsets? viewportInsets, + }) { + _metrics = SheetMetrics( + pixels: pixels ?? metrics.maybePixels, + minPixels: minPixels ?? metrics.maybeMinPixels, + maxPixels: maxPixels ?? metrics.maybeMaxPixels, + contentSize: contentSize ?? metrics.maybeContentSize, + viewportSize: viewportSize ?? metrics.maybeViewportSize, + viewportInsets: viewportInsets ?? metrics.maybeViewportInsets, + // Ensure that the devicePixelRatio is always up-to-date. + devicePixelRatio: context.devicePixelRatio, + ); + } + @mustCallSuper void takeOver(SheetExtent other) { assert(currentDrag == null); @@ -239,7 +263,7 @@ abstract class SheetExtent extends ChangeNotifier final oldMaxPixels = metrics.maybeMaxPixels; final oldMinPixels = metrics.maybeMinPixels; _oldContentSize = metrics.maybeContentSize; - _metrics = metrics.copyWith( + _updateMetrics( contentSize: contentSize, minPixels: minExtent.resolve(contentSize), maxPixels: maxExtent.resolve(contentSize), @@ -258,7 +282,7 @@ abstract class SheetExtent extends ChangeNotifier metrics.maybeViewportInsets != insets) { _oldViewportSize = metrics.maybeViewportSize; _oldViewportInsets = metrics.maybeViewportInsets; - _metrics = metrics.copyWith(viewportSize: size, viewportInsets: insets); + _updateMetrics(viewportSize: size, viewportInsets: insets); activity.didChangeViewportDimensions( _oldViewportSize, _oldViewportInsets, @@ -278,10 +302,7 @@ abstract class SheetExtent extends ChangeNotifier newMaxPixels != metrics.maybeMaxPixels) { final oldMinPixels = metrics.maybeMinPixels; final oldMaxPixels = metrics.maybeMaxPixels; - _metrics = metrics.copyWith( - minPixels: newMinPixels, - maxPixels: newMaxPixels, - ); + _updateMetrics(minPixels: newMinPixels, maxPixels: newMaxPixels); activity.didChangeBoundaryConstraints(oldMinPixels, oldMaxPixels); } } @@ -481,7 +502,7 @@ abstract class SheetExtent extends ChangeNotifier void correctPixels(double pixels) { if (metrics.maybePixels != pixels) { - _metrics = metrics.copyWith(pixels: pixels); + _updateMetrics(pixels: pixels); } } @@ -577,6 +598,7 @@ class SheetMetrics { required Size? contentSize, required Size? viewportSize, required EdgeInsets? viewportInsets, + this.devicePixelRatio = 1.0, }) : maybePixels = pixels, maybeMinPixels = minPixels, maybeMaxPixels = maxPixels, @@ -600,6 +622,10 @@ class SheetMetrics { final Size? maybeViewportSize; final EdgeInsets? maybeViewportInsets; + /// The [FlutterView.devicePixelRatio] of the view that the sheet + /// associated with this metrics object is drawn into. + final double devicePixelRatio; + /// The current extent of the sheet. double get pixels { assert(_debugAssertHasProperty('pixels', maybePixels)); @@ -667,7 +693,9 @@ class SheetMetrics { /// Whether the sheet is within the range of [minPixels] and [maxPixels] /// (inclusive of both bounds). bool get isPixelsInBounds => - hasDimensions && pixels.isInBounds(minPixels, maxPixels); + hasDimensions && + FloatComp.distance(devicePixelRatio) + .isInBounds(pixels, minPixels, maxPixels); /// Whether the sheet is outside the range of [minPixels] and [maxPixels]. bool get isPixelsOutOfBounds => !isPixelsInBounds; @@ -690,13 +718,13 @@ class SheetMetrics { /// Creates a copy of this object with the given fields replaced. SheetMetrics copyWith({ - SheetStatus? status, double? pixels, double? minPixels, double? maxPixels, Size? contentSize, Size? viewportSize, EdgeInsets? viewportInsets, + double? devicePixelRatio, }) { return SheetMetrics( pixels: pixels ?? maybePixels, @@ -705,6 +733,7 @@ class SheetMetrics { contentSize: contentSize ?? maybeContentSize, viewportSize: viewportSize ?? maybeViewportSize, viewportInsets: viewportInsets ?? maybeViewportInsets, + devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio, ); } @@ -718,7 +747,8 @@ class SheetMetrics { maybeMaxPixels == other.maxPixels && maybeContentSize == other.contentSize && maybeViewportSize == other.viewportSize && - maybeViewportInsets == other.viewportInsets); + maybeViewportInsets == other.viewportInsets && + devicePixelRatio == other.devicePixelRatio); @override int get hashCode => Object.hash( @@ -729,6 +759,7 @@ class SheetMetrics { maybeContentSize, maybeViewportSize, maybeViewportInsets, + devicePixelRatio, ); @override @@ -743,5 +774,6 @@ class SheetMetrics { contentSize: maybeContentSize, viewportSize: maybeViewportSize, viewportInsets: maybeViewportInsets, + devicePixelRatio: devicePixelRatio, ).toString(); } diff --git a/package/lib/src/foundation/sheet_physics.dart b/package/lib/src/foundation/sheet_physics.dart index 3f6dfa97..370e6a92 100644 --- a/package/lib/src/foundation/sheet_physics.dart +++ b/package/lib/src/foundation/sheet_physics.dart @@ -3,9 +3,11 @@ import 'dart:math'; import 'dart:ui'; import 'package:flutter/gestures.dart'; +import 'package:flutter/physics.dart'; import 'package:flutter/widgets.dart'; import '../internal/double_utils.dart'; +import '../internal/float_comp.dart'; import 'sheet_extent.dart'; /// The default [SpringDescription] used by [SheetPhysics] subclasses. @@ -165,6 +167,7 @@ class InterpolationSimulation extends Simulation { required this.end, required this.curve, required this.durationInSeconds, + super.tolerance, }) : assert(start != end), assert(durationInSeconds > 0); @@ -194,7 +197,7 @@ class InterpolationSimulation extends Simulation { @override bool isDone(double time) { - return x(time).isApprox(end); + return nearEqual(x(time), end, tolerance.distance); } } @@ -214,7 +217,8 @@ mixin _SnapToNearestMixin implements SnappingSheetBehavior { double? findSnapPixels(double velocity, SheetMetrics metrics) { assert(minFlingSpeed >= 0); - if (metrics.pixels.isOutOfBounds(metrics.minPixels, metrics.maxPixels)) { + if (FloatComp.distance(metrics.devicePixelRatio) + .isOutOfBounds(metrics.pixels, metrics.minPixels, metrics.maxPixels)) { return null; } @@ -258,7 +262,8 @@ class SnapToNearestEdge with _SnapToNearestMixin { @override (double, double) _getSnapBoundsContains(SheetMetrics metrics) { - assert(metrics.pixels.isInBounds(metrics.minPixels, metrics.maxPixels)); + assert(FloatComp.distance(metrics.devicePixelRatio) + .isInBounds(metrics.pixels, metrics.minPixels, metrics.maxPixels)); return (metrics.minPixels, metrics.maxPixels); } } @@ -291,8 +296,10 @@ class SnapToNearest with _SnapToNearestMixin { ..sort(); assert( - _snapTo.first.isGreaterThanOrApprox(metrics.minPixels) && - _snapTo.last.isLessThanOrApprox(metrics.maxPixels), + FloatComp.distance(metrics.devicePixelRatio) + .isGreaterThanOrApprox(_snapTo.first, metrics.minPixels) && + FloatComp.distance(metrics.devicePixelRatio) + .isLessThanOrApprox(_snapTo.last, metrics.maxPixels), 'The snap positions must be within the range of ' "'SheetMetrics.minPixels' and 'SheetMetrics.maxPixels'.", ); @@ -309,7 +316,8 @@ class SnapToNearest with _SnapToNearestMixin { var nearestSmaller = _snapTo[0]; var nearestGreater = _snapTo[1]; for (var index = 0; index < _snapTo.length - 1; index++) { - if (_snapTo[index].isLessThan(metrics.pixels)) { + if (FloatComp.distance(metrics.devicePixelRatio) + .isLessThan(_snapTo[index], metrics.pixels)) { nearestSmaller = _snapTo[index]; nearestGreater = _snapTo[index + 1]; } else { @@ -349,7 +357,9 @@ class SnappingSheetPhysics extends SheetPhysics with SheetPhysicsMixin { @override Simulation? createBallisticSimulation(double velocity, SheetMetrics metrics) { final snapPixels = snappingBehavior.findSnapPixels(velocity, metrics); - if (snapPixels != null && !metrics.pixels.isApprox(snapPixels)) { + if (snapPixels != null && + FloatComp.distance(metrics.devicePixelRatio) + .isNotApprox(snapPixels, metrics.pixels)) { return ScrollSpringSimulation( spring, metrics.pixels, @@ -532,7 +542,8 @@ class BouncingSheetPhysics extends SheetPhysics with SheetPhysicsMixin { _ => 0.0, }; - if (zeroFrictionOffset.isApprox(offset) || + if (FloatComp.distance(metrics.devicePixelRatio) + .isApprox(zeroFrictionOffset, offset) || // The friction is also not applied if the motion // direction is towards the content bounds. (currentPixels > maxPixels && offset < 0) || diff --git a/package/lib/src/internal/double_utils.dart b/package/lib/src/internal/double_utils.dart index 7763246c..676213fa 100644 --- a/package/lib/src/internal/double_utils.dart +++ b/package/lib/src/internal/double_utils.dart @@ -1,31 +1,12 @@ import 'dart:math'; -import 'package:flutter/physics.dart'; - extension DoubleUtils on double { - bool isApprox(double value) => - nearEqual(this, value, Tolerance.defaultTolerance.distance); - - bool isLessThan(double value) => this < value && !isApprox(value); - - bool isGreaterThan(double value) => this > value && !isApprox(value); - - bool isLessThanOrApprox(double value) => isLessThan(value) || isApprox(value); - - bool isGreaterThanOrApprox(double value) => - isGreaterThan(value) || isApprox(value); - - bool isOutOfBounds(double min, double max) => - isLessThan(min) || isGreaterThan(max); - - bool isInBounds(double min, double max) => !isOutOfBounds(min, max); - double clampAbs(double norm) => min(max(-norm, this), norm); double nearest(double a, double b) => (a - this).abs() < (b - this).abs() ? a : b; -} -double inverseLerp(double min, double max, double value) { - return min == max ? 1.0 : (value - min) / (max - min); + double inverseLerp(double min, double max) { + return min == max ? 1.0 : (this - min) / (max - min); + } } diff --git a/package/lib/src/internal/float_comp.dart b/package/lib/src/internal/float_comp.dart new file mode 100644 index 00000000..4f4acfe6 --- /dev/null +++ b/package/lib/src/internal/float_comp.dart @@ -0,0 +1,77 @@ +import 'package:flutter/physics.dart'; +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +/// Caches [FloatComp] instances for different epsilon values to avoid +/// object creations for every comparison. Although these instances may never +/// be released, the memory overhead is negligible as the device pixel ratio +/// rarely changes during the app's lifetime. +final _instanceForEpsilon = {}; + +// TODO: Reimplement this class as an extension type of [double] to avoid object creation. +/// A comparator for floating-point numbers in a certain precision. +/// +/// [FloatComp.distance] and [FloatComp.velocity] determine the [epsilon] based +/// on the given device pixel ratio, which is the number of physical pixels per +/// logical pixel. +@internal +class FloatComp { + /// Creates a [FloatComp] with the given [epsilon]. + factory FloatComp({required double epsilon}) { + return _instanceForEpsilon[epsilon] ??= FloatComp._(epsilon); + } + + /// Creates a [FloatComp] for comparing distances. + /// + /// The [devicePixelRatio] is the number of physical pixels per logical + /// pixel. This is typically obtained by [MediaQuery.devicePixelRatioOf]. + factory FloatComp.distance(double devicePixelRatio) { + return FloatComp(epsilon: 1e-3 / devicePixelRatio); + } + + /// Creates a [FloatComp] for comparing velocities. + /// + /// The [devicePixelRatio] is the number of physical pixels per logical + /// pixel. This is typically obtained by [MediaQuery.devicePixelRatioOf]. + factory FloatComp.velocity(double devicePixelRatio) { + return FloatComp(epsilon: 1e-4 / devicePixelRatio); + } + + const FloatComp._(this.epsilon); + + /// The maximum difference between two floating-point numbers to consider + /// them approximately equal. + final double epsilon; + + /// Returns `true` if [a] is approximately equal to [b]. + bool isApprox(double a, double b) => nearEqual(a, b, epsilon); + + /// Returns `true` if [a] is not approximately equal to [b]. + bool isNotApprox(double a, double b) => !isApprox(a, b); + + /// Returns `true` if [a] is less than [b] and not approximately equal to [b]. + bool isLessThan(double a, double b) => a < b && !isApprox(a, b); + + /// Returns `true` if [a] is greater than [b] and not approximately + /// equal to [b]. + bool isGreaterThan(double a, double b) => a > b && !isApprox(a, b); + + /// Returns `true` if [a] is less than [b] or approximately equal to [b]. + bool isLessThanOrApprox(double a, double b) => + isLessThan(a, b) || isApprox(a, b); + + /// Returns `true` if [a] is greater than [b] or approximately equal to [b]. + bool isGreaterThanOrApprox(double a, double b) => + isGreaterThan(a, b) || isApprox(a, b); + + /// Returns `true` if [a] is less than [min] or greater than [max]. + bool isOutOfBounds(double a, double min, double max) => + isLessThan(a, min) || isGreaterThan(a, max); + + /// Returns `true` if [a] is in the range `[min, max]`, inclusive. + bool isInBounds(double a, double min, double max) => + !isOutOfBounds(a, min, max); + + /// Returns [b] if [a] is approximately equal to [b], otherwise [a]. + double roundToIfApprox(double a, double b) => isApprox(a, b) ? b : a; +} diff --git a/package/lib/src/modal/cupertino.dart b/package/lib/src/modal/cupertino.dart index 72772713..cc41bd12 100644 --- a/package/lib/src/modal/cupertino.dart +++ b/package/lib/src/modal/cupertino.dart @@ -376,11 +376,10 @@ abstract class _BaseCupertinoModalSheetRoute extends PageRoute if (metrics.hasDimensions) { _cupertinoTransitionControllerOf[_previousRoute]?.value = min( controller!.value, - inverseLerp( + metrics.viewPixels.inverseLerp( // TODO: Make this configurable. metrics.viewportSize.height / 2, metrics.viewportSize.height, - metrics.viewPixels, ), ); } diff --git a/package/lib/src/modal/modal_sheet.dart b/package/lib/src/modal/modal_sheet.dart index 8d4660d5..988dda81 100644 --- a/package/lib/src/modal/modal_sheet.dart +++ b/package/lib/src/modal/modal_sheet.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import '../foundation/sheet_drag.dart'; import '../foundation/sheet_gesture_tamperer.dart'; -import '../internal/double_utils.dart'; +import '../internal/float_comp.dart'; const _minFlingVelocityToDismiss = 1.0; const _minDragDistanceToDismiss = 100.0; // Logical pixels. @@ -262,7 +262,8 @@ class _SwipeDismissibleController with SheetGestureTamperer { // Dominantly use the full pixels if it is in the middle of a transition. effectiveDragDelta = dragDelta; } else if (dragDelta < 0 && - !dragDelta.isApprox(minPDC) && + FloatComp.distance(MediaQuery.devicePixelRatioOf(_context)) + .isNotApprox(dragDelta, minPDC) && MediaQuery.viewInsetsOf(_context).bottom == 0) { // If the drag is downwards and the sheet may not consume the full pixels, // then use the remaining pixels as the effective drag delta. @@ -344,7 +345,8 @@ class _SwipeDismissibleController with SheetGestureTamperer { } else if (effectiveVelocity < 0) { // Flings down. invokePop = effectiveVelocity.abs() > _minFlingVelocityToDismiss; - } else if (effectiveVelocity.isApprox(0)) { + } else if (FloatComp.velocity(MediaQuery.devicePixelRatioOf(_context)) + .isApprox(effectiveVelocity, 0)) { assert(draggedDistance >= 0); // Dragged down enough to dismiss. invokePop = draggedDistance > _minDragDistanceToDismiss; diff --git a/package/lib/src/scrollable/scrollable_sheet_activity.dart b/package/lib/src/scrollable/scrollable_sheet_activity.dart index c7578116..d282d696 100644 --- a/package/lib/src/scrollable/scrollable_sheet_activity.dart +++ b/package/lib/src/scrollable/scrollable_sheet_activity.dart @@ -6,7 +6,7 @@ import 'package:meta/meta.dart'; import '../foundation/sheet_activity.dart'; import '../foundation/sheet_drag.dart'; -import '../internal/double_utils.dart'; +import '../internal/float_comp.dart'; import 'scrollable_sheet.dart'; import 'scrollable_sheet_extent.dart'; import 'sheet_content_scroll_activity.dart'; @@ -52,7 +52,8 @@ abstract class ScrollableSheetActivity } double _applyScrollOffset(double offset) { - if (offset.isApprox(0)) return 0; + final cmp = FloatComp.distance(owner.context.devicePixelRatio); + if (cmp.isApprox(offset, 0)) return 0; final position = scrollPosition; final maxPixels = owner.metrics.maxPixels; @@ -66,47 +67,47 @@ abstract class ScrollableSheetActivity if (offset > 0) { // If the sheet is not at top, drag it up as much as possible // until it reaches at 'maxPixels'. - if (newPixels.isLessThanOrApprox(maxPixels)) { + if (cmp.isLessThanOrApprox(newPixels, maxPixels)) { final physicsAppliedDelta = _applyPhysicsToOffset(delta); - assert(physicsAppliedDelta.isLessThanOrApprox(delta)); + assert(cmp.isLessThanOrApprox(physicsAppliedDelta, delta)); newPixels = min(newPixels + physicsAppliedDelta, maxPixels); delta -= newPixels - oldPixels; } // If the sheet is at the top, scroll the content up as much as possible. - if (newPixels.isGreaterThanOrApprox(maxPixels) && + if (cmp.isGreaterThanOrApprox(newPixels, maxPixels) && position.extentAfter > 0) { position.correctPixels(min(position.pixels + delta, maxScrollPixels)); delta -= position.pixels - oldScrollPixels; } // If the content cannot be scrolled up anymore, drag the sheet up // to make a bouncing effect (if needed). - if (position.pixels.isApprox(maxScrollPixels)) { + if (cmp.isApprox(position.pixels, maxScrollPixels)) { final physicsAppliedDelta = _applyPhysicsToOffset(delta); - assert(physicsAppliedDelta.isLessThanOrApprox(delta)); + assert(cmp.isLessThanOrApprox(physicsAppliedDelta, delta)); newPixels += physicsAppliedDelta; delta -= physicsAppliedDelta; } } else if (offset < 0) { // If the sheet is beyond 'maxPixels', drag it down as much // as possible until it reaches at 'maxPixels'. - if (newPixels.isGreaterThanOrApprox(maxPixels)) { + if (cmp.isGreaterThanOrApprox(newPixels, maxPixels)) { final physicsAppliedDelta = _applyPhysicsToOffset(delta); - assert(physicsAppliedDelta.abs().isLessThanOrApprox(delta.abs())); + assert(cmp.isLessThanOrApprox(physicsAppliedDelta.abs(), delta.abs())); newPixels = max(newPixels + physicsAppliedDelta, maxPixels); delta -= newPixels - oldPixels; } // If the sheet is not beyond 'maxPixels', scroll the content down // as much as possible. - if (newPixels.isLessThanOrApprox(maxPixels) && + if (cmp.isLessThanOrApprox(newPixels, maxPixels) && position.extentBefore > 0) { position.correctPixels(max(position.pixels + delta, minScrollPixels)); delta -= position.pixels - oldScrollPixels; } // If the content cannot be scrolled down anymore, drag the sheet down // to make a shrinking effect (if needed). - if (position.pixels.isApprox(minScrollPixels)) { + if (cmp.isApprox(position.pixels, minScrollPixels)) { final physicsAppliedDelta = _applyPhysicsToOffset(delta); - assert(physicsAppliedDelta.abs().isLessThanOrApprox(delta.abs())); + assert(cmp.isLessThanOrApprox(physicsAppliedDelta.abs(), delta.abs())); newPixels += physicsAppliedDelta; delta -= physicsAppliedDelta; } @@ -254,22 +255,25 @@ class BallisticScrollDrivenSheetActivity extends ScrollableSheetActivity @override void onAnimationTick() { + final cmp = FloatComp.distance(owner.context.devicePixelRatio); final delta = controller.value - _oldPixels; _oldPixels = controller.value; final overflow = _applyScrollOffset(delta); if (owner.metrics.pixels != _oldPixels) { owner.didUpdateMetrics(); } - if (!overflow.isApprox(0)) { + if (cmp.isNotApprox(overflow, 0)) { owner ..didOverflowBy(overflow) ..goIdleWithScrollPosition(); return; } + final scrollExtentBefore = scrollPosition.extentBefore; + final scrollExtentAfter = scrollPosition.extentAfter; final shouldInterruptBallisticScroll = - ((scrollPosition.extentBefore.isApprox(0) && velocity < 0) || - (scrollPosition.extentAfter.isApprox(0) && velocity > 0)) && + ((cmp.isApprox(scrollExtentBefore, 0) && velocity < 0) || + (cmp.isApprox(scrollExtentAfter, 0) && velocity > 0)) && owner.physics .shouldInterruptBallisticScroll(velocity, owner.metrics); diff --git a/package/lib/src/scrollable/scrollable_sheet_extent.dart b/package/lib/src/scrollable/scrollable_sheet_extent.dart index 88e5f78b..02ea2ec0 100644 --- a/package/lib/src/scrollable/scrollable_sheet_extent.dart +++ b/package/lib/src/scrollable/scrollable_sheet_extent.dart @@ -7,7 +7,7 @@ import 'package:meta/meta.dart'; import '../foundation/sheet_drag.dart'; import '../foundation/sheet_extent.dart'; import '../foundation/sheet_physics.dart'; -import '../internal/double_utils.dart'; +import '../internal/float_comp.dart'; import 'scrollable_sheet_activity.dart'; import 'scrollable_sheet_physics.dart'; import 'sheet_content_scroll_activity.dart'; @@ -164,7 +164,8 @@ class ScrollableSheetExtent extends SheetExtent required SheetContentScrollPosition scrollPosition, }) { assert(metrics.hasDimensions); - if (scrollPosition.pixels.isApprox(scrollPosition.minScrollExtent)) { + if (FloatComp.distance(context.devicePixelRatio) + .isApprox(scrollPosition.pixels, scrollPosition.minScrollExtent)) { final simulation = physics.createBallisticSimulation(velocity, metrics); if (simulation != null) { scrollPosition.goIdle(calledByOwner: true); @@ -179,12 +180,24 @@ class ScrollableSheetExtent extends SheetExtent final scrollableDistance = scrollPosition.maxScrollExtent - scrollPosition.minScrollExtent; final scrollPixelsForScrollPhysics = scrolledDistance + draggedDistance; + final maxScrollExtentForScrollPhysics = + draggableDistance + scrollableDistance; final scrollMetricsForScrollPhysics = scrollPosition.copyWith( minScrollExtent: 0, - // How many pixels the user can scroll/drag - maxScrollExtent: draggableDistance + scrollableDistance, - // How many pixels the user scrolled/dragged - pixels: scrollPixelsForScrollPhysics, + // How many pixels the user can scroll and drag + maxScrollExtent: maxScrollExtentForScrollPhysics, + // How many pixels the user has scrolled and dragged + pixels: FloatComp.distance(context.devicePixelRatio).roundToIfApprox( + // Round the scrollPixelsForScrollPhysics to the maxScrollExtent if + // necessary to prevents issues with floating-point precision errors. + // For example, issue #207 was caused by infinite recursion of + // SheetContentScrollPositionOwner.goBallisticWithScrollPosition calls, + // triggered by ScrollMetrics.outOfRange always being true in + // ScrollPhysics.createBallisticSimulation due to such a floating-point + // precision error. + scrollPixelsForScrollPhysics, + maxScrollExtentForScrollPhysics, + ), ); final scrollSimulation = scrollPosition.physics diff --git a/package/test/foundation/sheet_viewport_test.dart b/package/test/foundation/sheet_viewport_test.dart index dc2896f1..b0849d81 100644 --- a/package/test/foundation/sheet_viewport_test.dart +++ b/package/test/foundation/sheet_viewport_test.dart @@ -16,6 +16,9 @@ class _FakeNotificationContext extends Fake implements BuildContext { class _FakeSheetContext extends Fake implements SheetContext { @override final notificationContext = _FakeNotificationContext(); + + @override + double get devicePixelRatio => 3.0; } class _FakeSheetActivity extends SheetActivity {