From 3168e0dd6117e1fc3c1688b42b579001ac2daaf7 Mon Sep 17 00:00:00 2001 From: fujidaiti Date: Sun, 8 Sep 2024 20:48:39 +0900 Subject: [PATCH 1/9] Move minExtent and maxExtent from SheetExtent to SheetMetrics --- lib/src/foundation/sheet_extent.dart | 139 +++++++++++++---------- migrations/migration-guide-0.10.x.md | 5 + test/foundation/physics_test.dart | 4 +- test/foundation/sheet_activity_test.dart | 10 +- 4 files changed, 90 insertions(+), 68 deletions(-) create mode 100644 migrations/migration-guide-0.10.x.md diff --git a/lib/src/foundation/sheet_extent.dart b/lib/src/foundation/sheet_extent.dart index 19a30597..22c8a730 100644 --- a/lib/src/foundation/sheet_extent.dart +++ b/lib/src/foundation/sheet_extent.dart @@ -126,10 +126,12 @@ abstract class SheetExtent extends ChangeNotifier required SheetPhysics physics, this.debugLabel, SheetGestureTamperer? gestureTamperer, - }) : _gestureTamperer = gestureTamperer, - _minExtent = minExtent, - _maxExtent = maxExtent, - _physics = physics { + }) : _physics = physics, + _gestureTamperer = gestureTamperer, + _metrics = SheetMetrics.empty.copyWith( + minExtent: minExtent, + maxExtent: maxExtent, + ) { goIdle(); } @@ -146,16 +148,16 @@ abstract class SheetExtent extends ChangeNotifier /// /// The sheet may below this extent if the [physics] allows it. /// {@endtemplate} - Extent get minExtent => _minExtent; - Extent _minExtent; + // TODO: Remove this in favor of SheetMetrics.minExtent. + Extent get minExtent => _metrics.minExtent; /// {@template SheetExtent.maxExtent} /// The maximum extent of the sheet. /// /// The sheet may exceed this extent if the [physics] allows it. /// {@endtemplate} - Extent get maxExtent => _maxExtent; - Extent _maxExtent; + // TODO: Remove this in favor of SheetMetrics.maxExtent. + Extent get maxExtent => _metrics.maxExtent; /// {@template SheetExtent.physics} /// How the sheet extent should respond to user input. @@ -188,7 +190,7 @@ abstract class SheetExtent extends ChangeNotifier /// Snapshot of the current sheet's state. SheetMetrics get metrics => _metrics; - SheetMetrics _metrics = SheetMetrics.empty; + SheetMetrics _metrics; /// Updates the metrics with the given values. /// @@ -196,16 +198,16 @@ abstract class SheetExtent extends ChangeNotifier /// to ensure that the [SheetMetrics.devicePixelRatio] is always up-to-date. void _updateMetrics({ double? pixels, - double? minPixels, - double? maxPixels, + Extent? minExtent, + Extent? maxExtent, Size? contentSize, Size? viewportSize, EdgeInsets? viewportInsets, }) { _metrics = SheetMetrics( pixels: pixels ?? metrics.maybePixels, - minPixels: minPixels ?? metrics.maybeMinPixels, - maxPixels: maxPixels ?? metrics.maybeMaxPixels, + minExtent: minExtent ?? metrics.maybeMinExtent, + maxExtent: maxExtent ?? metrics.maybeMaxExtent, contentSize: contentSize ?? metrics.maybeContentSize, viewportSize: viewportSize ?? metrics.maybeViewportSize, viewportInsets: viewportInsets ?? metrics.maybeViewportInsets, @@ -263,11 +265,7 @@ abstract class SheetExtent extends ChangeNotifier final oldMaxPixels = metrics.maybeMaxPixels; final oldMinPixels = metrics.maybeMinPixels; _oldContentSize = metrics.maybeContentSize; - _updateMetrics( - contentSize: contentSize, - minPixels: minExtent.resolve(contentSize), - maxPixels: maxExtent.resolve(contentSize), - ); + _updateMetrics(contentSize: contentSize); activity.didChangeContentSize(_oldContentSize); if (oldMinPixels != metrics.minPixels || oldMaxPixels != metrics.maxPixels) { @@ -292,20 +290,11 @@ abstract class SheetExtent extends ChangeNotifier @mustCallSuper void applyNewBoundaryConstraints(Extent minExtent, Extent maxExtent) { - if (minExtent != _minExtent || maxExtent != _maxExtent) { - _minExtent = minExtent; - _maxExtent = maxExtent; - if (metrics.maybeContentSize case final contentSize?) { - final newMinPixels = minExtent.resolve(contentSize); - final newMaxPixels = maxExtent.resolve(contentSize); - if (newMinPixels != metrics.maybeMinPixels || - newMaxPixels != metrics.maybeMaxPixels) { - final oldMinPixels = metrics.maybeMinPixels; - final oldMaxPixels = metrics.maybeMaxPixels; - _updateMetrics(minPixels: newMinPixels, maxPixels: newMaxPixels); - activity.didChangeBoundaryConstraints(oldMinPixels, oldMaxPixels); - } - } + if (minExtent != this.minExtent || maxExtent != this.maxExtent) { + _updateMetrics(minExtent: minExtent, maxExtent: maxExtent); + final oldMinPixels = metrics.maybeMinPixels; + final oldMaxPixels = metrics.maybeMaxPixels; + activity.didChangeBoundaryConstraints(oldMinPixels, oldMaxPixels); } } @@ -568,36 +557,37 @@ abstract class SheetExtent extends ChangeNotifier } } -/// An immutable snapshot of the sheet's state. +/// An immutable snapshot of the state of a sheet. class SheetMetrics { - /// Creates an immutable snapshot of the sheet's state. + /// Creates an immutable snapshot of the state of a sheet. const SheetMetrics({ required double? pixels, - required double? minPixels, - required double? maxPixels, + required Extent? minExtent, + required Extent? maxExtent, required Size? contentSize, required Size? viewportSize, required EdgeInsets? viewportInsets, this.devicePixelRatio = 1.0, }) : maybePixels = pixels, - maybeMinPixels = minPixels, - maybeMaxPixels = maxPixels, + maybeMinExtent = minExtent, + maybeMaxExtent = maxExtent, maybeContentSize = contentSize, maybeViewportSize = viewportSize, maybeViewportInsets = viewportInsets; + /// An empty metrics object with all values set to null. static const empty = SheetMetrics( pixels: null, - minPixels: null, - maxPixels: null, + minExtent: null, + maxExtent: null, contentSize: null, viewportSize: null, viewportInsets: null, ); final double? maybePixels; - final double? maybeMinPixels; - final double? maybeMaxPixels; + final Extent? maybeMinExtent; + final Extent? maybeMaxExtent; final Size? maybeContentSize; final Size? maybeViewportSize; final EdgeInsets? maybeViewportInsets; @@ -606,24 +596,48 @@ class SheetMetrics { /// associated with this metrics object is drawn into. final double devicePixelRatio; - /// The current extent of the sheet. + double? get maybeMinPixels => switch ((maybeMinExtent, maybeContentSize)) { + (final minExtent?, final contentSize?) => + minExtent.resolve(contentSize), + _ => null, + }; + + double? get maybeMaxPixels => switch ((maybeMaxExtent, maybeContentSize)) { + (final maxExtent?, final contentSize?) => + maxExtent.resolve(contentSize), + _ => null, + }; + + /// The current extent of the sheet in pixels. double get pixels { assert(_debugAssertHasProperty('pixels', maybePixels)); return maybePixels!; } - /// The minimum extent of the sheet. + /// The minimum extent of the sheet in pixels. double get minPixels { assert(_debugAssertHasProperty('minPixels', maybeMinPixels)); return maybeMinPixels!; } - /// The maximum extent of the sheet. + /// The maximum extent of the sheet in pixels. double get maxPixels { assert(_debugAssertHasProperty('maxPixels', maybeMaxPixels)); return maybeMaxPixels!; } + /// The minimum extent of the sheet. + Extent get minExtent { + assert(_debugAssertHasProperty('minExtent', maybeMinExtent)); + return maybeMinExtent!; + } + + /// The maximum extent of the sheet. + Extent get maxExtent { + assert(_debugAssertHasProperty('maxExtent', maybeMaxExtent)); + return maybeMaxExtent!; + } + /// The size of the sheet's content. Size get contentSize { assert(_debugAssertHasProperty('contentSize', maybeContentSize)); @@ -660,12 +674,13 @@ class SheetMetrics { /// Whether the all metrics are available. /// - /// Returns true if all of [pixels], [minPixels], [maxPixels], - /// [contentSize], [viewportInsets], and [viewportSize] are not null. + /// Returns true if all of [maybePixels], [maybeMinPixels], [maybeMaxPixels], + /// [maybeContentSize], [maybeViewportSize], and [maybeViewportInsets] are not + /// null. bool get hasDimensions => maybePixels != null && - maybeMinPixels != null && - maybeMaxPixels != null && + maybeMinExtent != null && + maybeMaxExtent != null && maybeContentSize != null && maybeViewportSize != null && maybeViewportInsets != null; @@ -699,8 +714,8 @@ class SheetMetrics { /// Creates a copy of this object with the given fields replaced. SheetMetrics copyWith({ double? pixels, - double? minPixels, - double? maxPixels, + Extent? minExtent, + Extent? maxExtent, Size? contentSize, Size? viewportSize, EdgeInsets? viewportInsets, @@ -708,8 +723,8 @@ class SheetMetrics { }) { return SheetMetrics( pixels: pixels ?? maybePixels, - minPixels: minPixels ?? maybeMinPixels, - maxPixels: maxPixels ?? maybeMaxPixels, + minExtent: minExtent ?? maybeMinExtent, + maxExtent: maxExtent ?? maybeMaxExtent, contentSize: contentSize ?? maybeContentSize, viewportSize: viewportSize ?? maybeViewportSize, viewportInsets: viewportInsets ?? maybeViewportInsets, @@ -722,20 +737,20 @@ class SheetMetrics { identical(this, other) || (other is SheetMetrics && runtimeType == other.runtimeType && - maybePixels == other.pixels && - maybeMinPixels == other.minPixels && - maybeMaxPixels == other.maxPixels && - maybeContentSize == other.contentSize && - maybeViewportSize == other.viewportSize && - maybeViewportInsets == other.viewportInsets && + maybePixels == other.maybePixels && + maybeMinExtent == other.maybeMinExtent && + maybeMaxExtent == other.maybeMaxExtent && + maybeContentSize == other.maybeContentSize && + maybeViewportSize == other.maybeViewportSize && + maybeViewportInsets == other.maybeViewportInsets && devicePixelRatio == other.devicePixelRatio); @override int get hashCode => Object.hash( runtimeType, maybePixels, - maybeMinPixels, - maybeMaxPixels, + maybeMinExtent, + maybeMaxExtent, maybeContentSize, maybeViewportSize, maybeViewportInsets, @@ -751,6 +766,8 @@ class SheetMetrics { viewPixels: maybeViewPixels, minViewPixels: maybeMinViewPixels, maxViewPixels: maybeMaxViewPixels, + minExtent: maybeMinExtent, + maxExtent: maybeMaxExtent, contentSize: maybeContentSize, viewportSize: maybeViewportSize, viewportInsets: maybeViewportInsets, diff --git a/migrations/migration-guide-0.10.x.md b/migrations/migration-guide-0.10.x.md new file mode 100644 index 00000000..5fa73473 --- /dev/null +++ b/migrations/migration-guide-0.10.x.md @@ -0,0 +1,5 @@ +# Migration guide to 0.10.x from 0.9.x + +## Breaking changes in SheetMetrics + +`double? minPixels` and `double? maxPixels` parameters of the constructor and `copyWith` method have been replaced with `Extent? minExtent` and `Extent? maxExtent` respectively. The `minPixels` and `maxPixels` getters are still available in the new version. diff --git a/test/foundation/physics_test.dart b/test/foundation/physics_test.dart index db8054cf..d9fed541 100644 --- a/test/foundation/physics_test.dart +++ b/test/foundation/physics_test.dart @@ -14,8 +14,8 @@ class _SheetPhysicsWithDefaultConfiguration extends SheetPhysics } const _referenceSheetMetrics = SheetMetrics( - minPixels: 0, - maxPixels: 600, + minExtent: Extent.pixels(0), + maxExtent: Extent.pixels(600), pixels: 600, contentSize: Size(360, 600), viewportSize: Size(360, 700), diff --git a/test/foundation/sheet_activity_test.dart b/test/foundation/sheet_activity_test.dart index f298e0fb..3082bdb9 100644 --- a/test/foundation/sheet_activity_test.dart +++ b/test/foundation/sheet_activity_test.dart @@ -45,8 +45,8 @@ void main() { when(owner.metrics).thenReturn( const SheetMetrics( pixels: 300, - minPixels: 300, - maxPixels: 700, + minExtent: Extent.pixels(300), + maxExtent: Extent.pixels(700), contentSize: Size(400, 700), viewportSize: Size(400, 900), viewportInsets: EdgeInsets.zero, @@ -84,8 +84,8 @@ void main() { test('should absorb viewport changes', () { var metrics = const SheetMetrics( pixels: 300, - minPixels: 300, - maxPixels: 900, + minExtent: Extent.pixels(300), + maxExtent: Extent.pixels(900), contentSize: Size(400, 900), viewportSize: Size(400, 900), viewportInsets: EdgeInsets.zero, @@ -121,7 +121,7 @@ void main() { final oldViewportInsets = metrics.viewportInsets; final oldContentSize = metrics.contentSize; metrics = metrics.copyWith( - maxPixels: 850, + maxExtent: const Extent.pixels(850), viewportInsets: const EdgeInsets.only(bottom: 50), contentSize: const Size(400, 850), ); From aeb6f4a5210c1df8ac0a53207e1722692e87bbdb Mon Sep 17 00:00:00 2001 From: fujidaiti Date: Sun, 8 Sep 2024 22:55:26 +0900 Subject: [PATCH 2/9] Add findExtentToSettle to physics --- lib/src/foundation/sheet_activity.dart | 1 + lib/src/foundation/sheet_physics.dart | 343 ++++++++++++++++++------- lib/src/internal/float_comp.dart | 5 + test/foundation/physics_test.dart | 241 +++++++++++++++-- 4 files changed, 464 insertions(+), 126 deletions(-) diff --git a/lib/src/foundation/sheet_activity.dart b/lib/src/foundation/sheet_activity.dart index d070641a..d41be3d4 100644 --- a/lib/src/foundation/sheet_activity.dart +++ b/lib/src/foundation/sheet_activity.dart @@ -64,6 +64,7 @@ abstract class SheetActivity { void didChangeViewportDimensions(Size? oldSize, EdgeInsets? oldInsets) {} + // TODO: Change `double?` to `Extent?`. void didChangeBoundaryConstraints( double? oldMinPixels, double? oldMaxPixels, diff --git a/lib/src/foundation/sheet_physics.dart b/lib/src/foundation/sheet_physics.dart index 370e6a92..ef56d37a 100644 --- a/lib/src/foundation/sheet_physics.dart +++ b/lib/src/foundation/sheet_physics.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'dart:math'; import 'dart:ui'; +import 'package:collection/collection.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/physics.dart'; import 'package:flutter/widgets.dart'; @@ -71,9 +72,18 @@ abstract class SheetPhysics { Simulation? createBallisticSimulation(double velocity, SheetMetrics metrics); + // TODO: Remove this in favor of findNearestDetent(). + @Deprecated('Use findSettledExtent instead.') Simulation? createSettlingSimulation(SheetMetrics metrics); + + /// {@template SheetPhysics.findSettledExtent} + /// Returns an extent to which a sheet should eventually settle + /// based on the current [metrics] and the [velocity] of a sheet. + /// {@endtemplate} + Extent findSettledExtent(double velocity, SheetMetrics metrics); } +/// A mixin that provides default implementations for [SheetPhysics] methods. mixin SheetPhysicsMixin on SheetPhysics { SpringDescription get spring => kDefaultSheetSpring; @@ -113,24 +123,28 @@ mixin SheetPhysicsMixin on SheetPhysics { Simulation? createBallisticSimulation(double velocity, SheetMetrics metrics) { 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, - ); + // Ensure that this method always uses the default implementation + // of findSettledExtent. + final detent = _findSettledExtentInternal(velocity, metrics) + .resolve(metrics.contentSize); + if (FloatComp.distance(metrics.devicePixelRatio) + .isNotApprox(detent, metrics.pixels)) { + final direction = (detent - metrics.pixels).sign; + return ScrollSpringSimulation( + spring, + metrics.pixels, + detent, + // 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, + ); + } + + return null; } @override @@ -153,6 +167,28 @@ mixin SheetPhysicsMixin on SheetPhysics { ), ); } + + /// Returns the closer of [SheetMetrics.minExtent] or [SheetMetrics.maxExtent] + /// to the current sheet position if it is out of bounds, regardless of the + /// [velocity]. Otherwise, it returns the current position. + @override + Extent findSettledExtent(double velocity, SheetMetrics metrics) { + return _findSettledExtentInternal(velocity, metrics); + } + + Extent _findSettledExtentInternal(double velocity, SheetMetrics metrics) { + final pixels = metrics.pixels; + final minPixels = metrics.minPixels; + final maxPixels = metrics.maxPixels; + if (FloatComp.distance(metrics.devicePixelRatio) + .isInBounds(pixels, minPixels, maxPixels)) { + return Extent.pixels(pixels); + } else if ((pixels - minPixels).abs() < (pixels - maxPixels).abs()) { + return metrics.minExtent; + } else { + return metrics.maxExtent; + } + } } /// A [Simulation] that interpolates between two values over a given duration. @@ -202,35 +238,11 @@ class InterpolationSimulation extends Simulation { } abstract interface class SnappingSheetBehavior { - double? findSnapPixels(double velocity, SheetMetrics metrics); -} - -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; - - @protected - (double, double) _getSnapBoundsContains(SheetMetrics metrics); - - @override - double? findSnapPixels(double velocity, SheetMetrics metrics) { - assert(minFlingSpeed >= 0); - - if (FloatComp.distance(metrics.devicePixelRatio) - .isOutOfBounds(metrics.pixels, metrics.minPixels, metrics.maxPixels)) { - return null; - } - - final (nearestSmaller, nearestGreater) = _getSnapBoundsContains(metrics); - if (velocity.abs() < minFlingSpeed) { - return metrics.pixels.nearest(nearestSmaller, nearestGreater); - } else if (velocity < 0) { - return nearestSmaller; - } else { - return nearestGreater; - } - } + /// {@macro SheetPhysics.findSettledExtent} + /// + /// Returning `null` indicates that this behavior has no preference for + /// for where the sheet should settle. + Extent? findSettledExtent(double velocity, SheetMetrics metrics); } /// A [SnappingSheetBehavior] that snaps to either [SheetMetrics.minPixels] @@ -247,7 +259,7 @@ mixin _SnapToNearestMixin implements SnappingSheetBehavior { /// 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 { +class SnapToNearestEdge implements SnappingSheetBehavior { /// Creates a [SnappingSheetBehavior] that snaps to either /// [SheetMetrics.minPixels] or [SheetMetrics.maxPixels]. /// @@ -257,76 +269,199 @@ class SnapToNearestEdge with _SnapToNearestMixin { this.minFlingSpeed = kMinFlingVelocity, }) : assert(minFlingSpeed >= 0); - @override + /// The lowest speed (in logical pixels per second) + /// at which a gesture is considered to be a fling. final double minFlingSpeed; @override - (double, double) _getSnapBoundsContains(SheetMetrics metrics) { - assert(FloatComp.distance(metrics.devicePixelRatio) - .isInBounds(metrics.pixels, metrics.minPixels, metrics.maxPixels)); - return (metrics.minPixels, metrics.maxPixels); + Extent? findSettledExtent(double velocity, SheetMetrics metrics) { + assert(minFlingSpeed >= 0); + final pixels = metrics.pixels; + final minPixels = metrics.minPixels; + final maxPixels = metrics.maxPixels; + final cmp = FloatComp.distance(metrics.devicePixelRatio); + if (cmp.isOutOfBounds(pixels, minPixels, maxPixels)) { + return null; + } + if (velocity >= minFlingSpeed) { + return metrics.maxExtent; + } + if (velocity <= -minFlingSpeed) { + return metrics.minExtent; + } + if (cmp.isApprox(pixels, minPixels) || cmp.isApprox(pixels, maxPixels)) { + return null; + } + return (pixels - minPixels).abs() < (pixels - maxPixels).abs() + ? metrics.minExtent + : metrics.maxExtent; } } -class SnapToNearest with _SnapToNearestMixin { - SnapToNearest({ +class SnapToNearest implements SnappingSheetBehavior { + const SnapToNearest({ required this.snapTo, this.minFlingSpeed = kMinFlingVelocity, - }) : assert(snapTo.isNotEmpty), - assert(minFlingSpeed >= 0); + }) : assert(minFlingSpeed >= 0); + // TODO: Rename to `detents`. final List snapTo; - @override + /// The lowest speed (in logical pixels per second) + /// at which a gesture is considered to be a fling. 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? _cachedContentSize; - - void _ensureCacheIsValid(SheetMetrics metrics) { - if (_cachedContentSize != metrics.contentSize) { - _cachedContentSize = metrics.contentSize; - _snapTo = snapTo - .map((e) => e.resolve(metrics.contentSize)) - .toList(growable: false) - ..sort(); - - assert( - 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'.", - ); + @override + Extent? findSettledExtent(double velocity, SheetMetrics metrics) { + if (snapTo.length <= 1) { + return snapTo.firstOrNull; } - } - @override - (double, double) _getSnapBoundsContains(SheetMetrics metrics) { - _ensureCacheIsValid(metrics); - if (_snapTo.length == 1) { - return (_snapTo.first, _snapTo.first); + final (sortedDetents, nearestIndex) = + sortExtentsAndFindNearest(snapTo, metrics.pixels, metrics.contentSize); + final cmp = FloatComp.distance(metrics.devicePixelRatio); + final pixels = metrics.pixels; + + if (cmp.isOutOfBounds( + pixels, + sortedDetents.first.resolved, + sortedDetents.last.resolved, + )) { + return null; + } + + final nearest = sortedDetents[nearestIndex]; + if (velocity.abs() < minFlingSpeed) { + return cmp.isApprox(pixels, nearest.resolved) ? null : nearest.extent; } - var nearestSmaller = _snapTo[0]; - var nearestGreater = _snapTo[1]; - for (var index = 0; index < _snapTo.length - 1; index++) { - if (FloatComp.distance(metrics.devicePixelRatio) - .isLessThan(_snapTo[index], metrics.pixels)) { - nearestSmaller = _snapTo[index]; - nearestGreater = _snapTo[index + 1]; - } else { - break; - } + final int floorIndex; + final int ceilIndex; + if (cmp.isApprox(pixels, nearest.resolved)) { + floorIndex = max(nearestIndex - 1, 0); + ceilIndex = min(nearestIndex + 1, sortedDetents.length - 1); + } else if (pixels < nearest.resolved) { + floorIndex = max(nearestIndex - 1, 0); + ceilIndex = nearestIndex; + } else { + assert(pixels > nearest.resolved); + floorIndex = nearestIndex; + ceilIndex = min(nearestIndex + 1, sortedDetents.length - 1); } - return (nearestSmaller, nearestGreater); + assert(velocity.abs() >= minFlingSpeed); + return velocity < 0 + ? sortedDetents[floorIndex].extent + : sortedDetents[ceilIndex].extent; + } +} + +typedef _SortedExtentList = List<({Extent extent, double resolved})>; + +/// Sorts the [extents] based on their resolved values and finds the nearest +/// extent to the [pixels]. +/// +/// Returns a sorted copy of the [extents] and the index of the nearest extent. +/// Note that the returned list may have a fixed length for better performance. +@visibleForTesting +(_SortedExtentList, int) sortExtentsAndFindNearest( + List extents, + double pixels, + Size contentSize, +) { + assert(extents.isNotEmpty); + switch (extents) { + case [final a, final b]: + return _sortTwoExtentsAndFindNearest(a, b, pixels, contentSize); + case [final a, final b, final c]: + return _sortThreeExtentsAndFindNearest(a, b, c, pixels, contentSize); + case _: + final sortedExtents = extents + .map((e) => (extent: e, resolved: e.resolve(contentSize))) + .sorted((a, b) => a.resolved.compareTo(b.resolved)); + final nearestIndex = sortedExtents + .mapIndexed((i, e) => (index: i, dist: (pixels - e.resolved).abs())) + .reduce((a, b) => a.dist < b.dist ? a : b) + .index; + return (sortedExtents, nearestIndex); + } +} + +/// Constant time sorting and nearest neighbor search for two [Extent]s. +(_SortedExtentList, int) _sortTwoExtentsAndFindNearest( + Extent a, + Extent b, + double pixels, + Size contentSize, +) { + var first = (extent: a, resolved: a.resolve(contentSize)); + var second = (extent: b, resolved: b.resolve(contentSize)); + + if (first.resolved > second.resolved) { + final temp = first; + first = second; + second = temp; + } + + final distToFirst = (pixels - first.resolved).abs(); + final distToSecond = (pixels - second.resolved).abs(); + final nearestIndex = distToFirst < distToSecond ? 0 : 1; + + return ( + // Create a fixed-length list. + List.filled(2, first)..[1] = second, + nearestIndex, + ); +} + +/// Constant time sorting and nearest neighbor search for three [Extent]s. +(_SortedExtentList, int) _sortThreeExtentsAndFindNearest( + Extent a, + Extent b, + Extent c, + double pixels, + Size contentSize, +) { + var first = (extent: a, resolved: a.resolve(contentSize)); + var second = (extent: b, resolved: b.resolve(contentSize)); + var third = (extent: c, resolved: c.resolve(contentSize)); + + if (first.resolved > second.resolved) { + final temp = first; + first = second; + second = temp; } + if (second.resolved > third.resolved) { + final temp = second; + second = third; + third = temp; + } + if (first.resolved > second.resolved) { + final temp = first; + first = second; + second = temp; + } + + final distToFirst = (pixels - first.resolved).abs(); + final distToSecond = (pixels - second.resolved).abs(); + final distToThird = (pixels - third.resolved).abs(); + + final int nearestIndex; + if (distToFirst < distToSecond && distToFirst < distToThird) { + nearestIndex = 0; + } else if (distToSecond < distToFirst && distToSecond < distToThird) { + nearestIndex = 1; + } else { + nearestIndex = 2; + } + + return ( + // Create a fixed-length list. + List.filled(3, first) + ..[1] = second + ..[2] = third, + nearestIndex, + ); } class SnappingSheetPhysics extends SheetPhysics with SheetPhysicsMixin { @@ -356,20 +491,28 @@ class SnappingSheetPhysics extends SheetPhysics with SheetPhysicsMixin { @override Simulation? createBallisticSimulation(double velocity, SheetMetrics metrics) { - final snapPixels = snappingBehavior.findSnapPixels(velocity, metrics); - if (snapPixels != null && + final detent = snappingBehavior + .findSettledExtent(velocity, metrics) + ?.resolve(metrics.contentSize); + if (detent != null && FloatComp.distance(metrics.devicePixelRatio) - .isNotApprox(snapPixels, metrics.pixels)) { + .isNotApprox(detent, metrics.pixels)) { return ScrollSpringSimulation( spring, metrics.pixels, - snapPixels, + detent, velocity, ); } else { return super.createBallisticSimulation(velocity, metrics); } } + + @override + Extent findSettledExtent(double velocity, SheetMetrics metrics) { + return snappingBehavior.findSettledExtent(velocity, metrics) ?? + super.findSettledExtent(velocity, metrics); + } } class ClampingSheetPhysics extends SheetPhysics with SheetPhysicsMixin { diff --git a/lib/src/internal/float_comp.dart b/lib/src/internal/float_comp.dart index 4f4acfe6..bddca54a 100644 --- a/lib/src/internal/float_comp.dart +++ b/lib/src/internal/float_comp.dart @@ -68,6 +68,11 @@ class FloatComp { bool isOutOfBounds(double a, double min, double max) => isLessThan(a, min) || isGreaterThan(a, max); + /// Returns `true` if [a] is out of bounds or approximately equal to [min] + /// or [max]. + bool isOutOfBoundsOrApprox(double a, double min, double max) => + isOutOfBounds(a, min, max) || isApprox(a, min) || isApprox(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); diff --git a/test/foundation/physics_test.dart b/test/foundation/physics_test.dart index d9fed541..d0911158 100644 --- a/test/foundation/physics_test.dart +++ b/test/foundation/physics_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:smooth_sheets/smooth_sheets.dart'; +import 'package:smooth_sheets/src/foundation/sheet_physics.dart'; class _SheetPhysicsWithDefaultConfiguration extends SheetPhysics with SheetPhysicsMixin { @@ -211,6 +212,61 @@ void main() { moreOrLessEquals(_referenceSheetMetrics.maxPixels), ); }); + + test('findSettledExtent', () { + expect( + physicsUnderTest.findSettledExtent(0, _positionAtMiddle), + Extent.pixels(_positionAtMiddle.pixels), + reason: 'Should return the current position if it is in bounds', + ); + expect( + physicsUnderTest.findSettledExtent(1000, _positionAtMiddle), + Extent.pixels(_positionAtMiddle.pixels), + reason: 'The velocity should not affect the result', + ); + + final overDraggedPosition = _positionAtTopEdge.copyWith( + pixels: _positionAtTopEdge.maxPixels + 10, + ); + expect( + physicsUnderTest.findSettledExtent(0, overDraggedPosition), + _referenceSheetMetrics.maxExtent, + reason: 'Should return the max extent if the position ' + 'is out of the upper bound', + ); + expect( + physicsUnderTest.findSettledExtent(1000, overDraggedPosition), + _referenceSheetMetrics.maxExtent, + reason: 'The velocity should not affect the result', + ); + + final underDraggedPosition = _positionAtBottomEdge.copyWith( + pixels: _positionAtBottomEdge.minPixels - 10, + ); + expect( + physicsUnderTest.findSettledExtent(0, underDraggedPosition), + _referenceSheetMetrics.minExtent, + reason: 'Should return the min extent if the position ' + 'is out of the lower bound', + ); + expect( + physicsUnderTest.findSettledExtent(1000, underDraggedPosition), + _referenceSheetMetrics.minExtent, + reason: 'The velocity should not affect the result', + ); + + // Boundary conditions + expect( + physicsUnderTest.findSettledExtent(1000, _positionAtTopEdge), + _referenceSheetMetrics.maxExtent, + reason: 'Should return the max extent if the position is at the upper bound', + ); + expect( + physicsUnderTest.findSettledExtent(1000, _positionAtBottomEdge), + _referenceSheetMetrics.minExtent, + reason: 'Should return the min extent if the position is at the lower bound', + ); + }); }); group('$SnapToNearestEdge', () { @@ -229,23 +285,23 @@ void main() { ); expect( - behaviorUnderTest.findSnapPixels(0, positionAtNearTopEdge), - moreOrLessEquals(_referenceSheetMetrics.maxPixels), + behaviorUnderTest.findSettledExtent(0, positionAtNearTopEdge), + _referenceSheetMetrics.maxExtent, ); expect( - behaviorUnderTest.findSnapPixels(0, positionAtNearBottomEdge), - moreOrLessEquals(_referenceSheetMetrics.minPixels), + behaviorUnderTest.findSettledExtent(0, positionAtNearBottomEdge), + _referenceSheetMetrics.minExtent, ); }); test('is aware of fling gesture direction', () { expect( - behaviorUnderTest.findSnapPixels(50, _positionAtBottomEdge), - moreOrLessEquals(_referenceSheetMetrics.maxPixels), + behaviorUnderTest.findSettledExtent(50, _positionAtBottomEdge), + _referenceSheetMetrics.maxExtent, ); expect( - behaviorUnderTest.findSnapPixels(-50, _positionAtTopEdge), - moreOrLessEquals(_referenceSheetMetrics.minPixels), + behaviorUnderTest.findSettledExtent(-50, _positionAtTopEdge), + _referenceSheetMetrics.minExtent, ); }); @@ -258,14 +314,29 @@ void main() { ); expect( - behaviorUnderTest.findSnapPixels(0, overDraggedPosition), + behaviorUnderTest.findSettledExtent(0, overDraggedPosition), isNull, ); expect( - behaviorUnderTest.findSnapPixels(0, underDraggedPosition), + behaviorUnderTest.findSettledExtent(0, underDraggedPosition), isNull, ); }); + + test('Boundary conditions', () { + expect( + behaviorUnderTest.findSettledExtent(0, _positionAtTopEdge), isNull); + expect(behaviorUnderTest.findSettledExtent(0, _positionAtBottomEdge), + isNull); + expect( + behaviorUnderTest.findSettledExtent(-50, _positionAtTopEdge), + _referenceSheetMetrics.minExtent, + ); + expect( + behaviorUnderTest.findSettledExtent(50, _positionAtBottomEdge), + _referenceSheetMetrics.maxExtent, + ); + }); }); group('$SnapToNearest', () { late SnapToNearest behaviorUnderTest; @@ -293,16 +364,16 @@ void main() { ); expect( - behaviorUnderTest.findSnapPixels(0, positionAtNearTopEdge), - moreOrLessEquals(_referenceSheetMetrics.maxPixels), + behaviorUnderTest.findSettledExtent(0, positionAtNearTopEdge), + Extent.pixels(_referenceSheetMetrics.maxPixels), ); expect( - behaviorUnderTest.findSnapPixels(0, positionAtNearMiddle), - moreOrLessEquals(_positionAtMiddle.pixels), + behaviorUnderTest.findSettledExtent(0, positionAtNearMiddle), + Extent.pixels(_positionAtMiddle.pixels), ); expect( - behaviorUnderTest.findSnapPixels(0, positionAtNearBottomEdge), - moreOrLessEquals(_referenceSheetMetrics.minPixels), + behaviorUnderTest.findSettledExtent(0, positionAtNearBottomEdge), + Extent.pixels(_referenceSheetMetrics.minPixels), ); }); @@ -315,23 +386,23 @@ void main() { ); // Flings up at the bottom edge expect( - behaviorUnderTest.findSnapPixels(50, _positionAtBottomEdge), - moreOrLessEquals(_positionAtMiddle.pixels), + behaviorUnderTest.findSettledExtent(50, _positionAtBottomEdge), + Extent.pixels(_positionAtMiddle.pixels), ); // Flings up at the slightly above the middle position expect( - behaviorUnderTest.findSnapPixels(50, positionAtAboveMiddle), - moreOrLessEquals(_positionAtTopEdge.pixels), + behaviorUnderTest.findSettledExtent(50, positionAtAboveMiddle), + Extent.pixels(_positionAtTopEdge.pixels), ); // Flings down at the top edge expect( - behaviorUnderTest.findSnapPixels(-50, _positionAtTopEdge), - moreOrLessEquals(_positionAtMiddle.pixels), + behaviorUnderTest.findSettledExtent(-50, _positionAtTopEdge), + Extent.pixels(_positionAtMiddle.pixels), ); // Flings down at the slightly below the middle position expect( - behaviorUnderTest.findSnapPixels(-50, positionAtBelowMiddle), - moreOrLessEquals(_positionAtBottomEdge.pixels), + behaviorUnderTest.findSettledExtent(-50, positionAtBelowMiddle), + Extent.pixels(_positionAtBottomEdge.pixels), ); }); @@ -344,14 +415,77 @@ void main() { ); expect( - behaviorUnderTest.findSnapPixels(0, overDraggedPosition), + behaviorUnderTest.findSettledExtent(0, overDraggedPosition), + isNull, + ); + expect( + behaviorUnderTest.findSettledExtent(0, underDraggedPosition), + isNull, + ); + }); + + test('Boundary condition: a drag ends exactly at the top detent', () { + expect( + behaviorUnderTest.findSettledExtent(0, _positionAtTopEdge), isNull, ); + }); + + test('Boundary condition: flings up exactly at the top detent', () { + expect( + behaviorUnderTest.findSettledExtent(50, _positionAtTopEdge), + Extent.pixels(_positionAtTopEdge.pixels), + ); + }); + + test('Boundary condition: flings down exactly at the top detent', () { + expect( + behaviorUnderTest.findSettledExtent(-50, _positionAtTopEdge), + Extent.pixels(_positionAtMiddle.pixels), + ); + }); + + test('Boundary condition: a drag ends exactly at the middle detent', () { + expect( + behaviorUnderTest.findSettledExtent(0, _positionAtMiddle), + isNull, + ); + }); + + test('Boundary condition: flings up exactly at the middle detent', () { + expect( + behaviorUnderTest.findSettledExtent(50, _positionAtMiddle), + Extent.pixels(_positionAtTopEdge.pixels), + ); + }); + + test('Boundary condition: flings down exactly at the middle detent', () { + expect( + behaviorUnderTest.findSettledExtent(-50, _positionAtMiddle), + Extent.pixels(_positionAtBottomEdge.pixels), + ); + }); + + test('Boundary condition: a drag ends exactly at the bottom detent', () { expect( - behaviorUnderTest.findSnapPixels(0, underDraggedPosition), + behaviorUnderTest.findSettledExtent(0, _positionAtBottomEdge), isNull, ); }); + + test('Boundary condition: flings up exactly at the bottom detent', () { + expect( + behaviorUnderTest.findSettledExtent(50, _positionAtBottomEdge), + Extent.pixels(_positionAtMiddle.pixels), + ); + }); + + test('Boundary condition: flings down exactly at the bottom detent', () { + expect( + behaviorUnderTest.findSettledExtent(-50, _positionAtBottomEdge), + Extent.pixels(_positionAtBottomEdge.pixels), + ); + }); }); test('FixedBouncingBehavior returns same value for same input metrics', () { @@ -443,4 +577,59 @@ void main() { ); }); }); + + group('sortExtentsAndFindNearest', () { + test('with two extents', () { + final (sortedExtents, nearestIndex) = sortExtentsAndFindNearest( + const [Extent.proportional(1), Extent.pixels(0)], + 250, + const Size(400, 600), + ); + expect(sortedExtents, const [ + (extent: Extent.pixels(0), resolved: 0), + (extent: Extent.proportional(1), resolved: 600), + ]); + expect(nearestIndex, 0); + }); + + test('with three extents', () { + final (sortedExtents, nearestIndex) = sortExtentsAndFindNearest( + const [ + Extent.proportional(1), + Extent.proportional(0.5), + Extent.pixels(0), + ], + 250, + const Size(400, 600), + ); + expect(sortedExtents, const [ + (extent: Extent.pixels(0), resolved: 0), + (extent: Extent.proportional(0.5), resolved: 300), + (extent: Extent.proportional(1), resolved: 600), + ]); + expect(nearestIndex, 1); + }); + + test('with more than three extents', () { + final (sortedExtents, nearestIndex) = sortExtentsAndFindNearest( + const [ + Extent.proportional(0.25), + Extent.proportional(0.5), + Extent.proportional(0.75), + Extent.pixels(0), + Extent.proportional(1), + ], + 500, + const Size(400, 600), + ); + expect(sortedExtents, const [ + (extent: Extent.pixels(0), resolved: 0), + (extent: Extent.proportional(0.25), resolved: 150), + (extent: Extent.proportional(0.5), resolved: 300), + (extent: Extent.proportional(0.75), resolved: 450), + (extent: Extent.proportional(1), resolved: 600), + ]); + expect(nearestIndex, 3); + }); + }); } From ae96cd577f8524d8a0c46ba0b90a2834e0f249f8 Mon Sep 17 00:00:00 2001 From: fujidaiti Date: Thu, 12 Sep 2024 23:19:25 +0900 Subject: [PATCH 3/9] Add settling activity --- lib/src/foundation/sheet_activity.dart | 138 +++++++++++++++++++++++++ lib/src/foundation/sheet_extent.dart | 7 ++ 2 files changed, 145 insertions(+) diff --git a/lib/src/foundation/sheet_activity.dart b/lib/src/foundation/sheet_activity.dart index d41be3d4..28258d1a 100644 --- a/lib/src/foundation/sheet_activity.dart +++ b/lib/src/foundation/sheet_activity.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math'; import 'dart:ui'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; @@ -246,6 +247,7 @@ class BallisticSheetActivity extends SheetActivity }); final Simulation simulation; + // TODO: Use controller.value instead. late double _lastAnimatedValue; @override @@ -279,6 +281,142 @@ class BallisticSheetActivity extends SheetActivity void onAnimationEnd() { owner.goBallistic(0); } + + @override + void didFinalizeDimensions( + Size? oldContentSize, + Size? oldViewportSize, + EdgeInsets? oldViewportInsets, + ) { + if (oldContentSize == null && + oldViewportSize == null && + oldViewportInsets == null) { + return; + } + + final oldMetrics = owner.metrics.copyWith( + contentSize: oldContentSize, + viewportSize: oldViewportSize, + viewportInsets: oldViewportInsets, + ); + + // TODO: DRY with other activities. + // Appends the delta of the bottom inset (typically the keyboard height) + // to keep the visual sheet position unchanged. + final newInsets = owner.metrics.viewportInsets; + final oldInsets = oldViewportInsets ?? newInsets; + final deltaInsetBottom = newInsets.bottom - oldInsets.bottom; + final newPixels = owner.metrics.pixels - deltaInsetBottom; + owner + ..setPixels(newPixels) + ..didUpdateMetrics(); + + if (owner.physics.findSettledExtent(velocity, oldMetrics) case final detent + when detent.resolve(owner.metrics.contentSize) != newPixels) { + owner.beginActivity( + SettlingSheetActivity.withDuration( + const Duration(milliseconds: 200), + destination: detent, + ), + ); + } + } +} + +class SettlingSheetActivity extends SheetActivity { + SettlingSheetActivity({ + required this.destination, + required double velocity, + }) : assert(velocity > 0), + _velocity = velocity, + duration = null; + + SettlingSheetActivity.withDuration( + Duration this.duration, { + required this.destination, + }) : assert(duration > Duration.zero); + + final Duration? duration; + final Extent destination; + late final Ticker _ticker; + + /// The amount of time that has passed between the time the animation + /// started and the most recent tick of the animation. + var _elapsedDuration = Duration.zero; + + @override + double get velocity => _velocity; + late double _velocity; + + @override + SheetStatus get status => SheetStatus.animating; + + @override + void init(SheetExtent owner) { + super.init(owner); + _ticker = owner.context.vsync.createTicker(_tick)..start(); + _invalidateVelocity(); + } + + void _tick(Duration elapsedDuration) { + final elapsedFrameTime = + (elapsedDuration - _elapsedDuration).inMicroseconds / + Duration.microsecondsPerSecond; + final destination = this.destination.resolve(owner.metrics.contentSize); + final pixels = owner.metrics.pixels; + final newPixels = destination > pixels + ? min(destination, pixels + velocity * elapsedFrameTime) + : max(destination, pixels - velocity * elapsedFrameTime); + owner + ..setPixels(newPixels) + ..didUpdateMetrics(); + + _elapsedDuration = elapsedDuration; + + if (newPixels == destination) { + owner.goBallistic(0); + } + } + + @override + void didFinalizeDimensions( + Size? oldContentSize, + Size? oldViewportSize, + EdgeInsets? oldViewportInsets, + ) { + if (oldViewportInsets != null) { + // TODO: DRY with other activities. + // Appends the delta of the bottom inset (typically the keyboard height) + // to keep the visual sheet position unchanged. + final newInsets = owner.metrics.viewportInsets; + final oldInsets = oldViewportInsets; + final deltaInsetBottom = newInsets.bottom - oldInsets.bottom; + final newPixels = owner.metrics.pixels - deltaInsetBottom; + owner + ..setPixels(newPixels) + ..didUpdateMetrics(); + } + + _invalidateVelocity(); + } + + @override + void dispose() { + _ticker.dispose(); + super.dispose(); + } + + void _invalidateVelocity() { + if (duration case final duration?) { + final remainingSeconds = (duration - _elapsedDuration).inMicroseconds / + Duration.microsecondsPerSecond; + final destination = this.destination.resolve(owner.metrics.contentSize); + final pixels = owner.metrics.pixels; + _velocity = remainingSeconds > 0 + ? (destination - pixels).abs() / remainingSeconds + : (destination - pixels).abs(); + } + } } @internal diff --git a/lib/src/foundation/sheet_extent.dart b/lib/src/foundation/sheet_extent.dart index 22c8a730..77a0f172 100644 --- a/lib/src/foundation/sheet_extent.dart +++ b/lib/src/foundation/sheet_extent.dart @@ -64,6 +64,9 @@ class ProportionalExtent implements Extent { @override int get hashCode => Object.hash(runtimeType, factor); + + @override + String toString() => '$ProportionalExtent(factor: $factor)'; } /// An extent that has an concrete value in pixels. @@ -88,6 +91,9 @@ class FixedExtent implements Extent { @override int get hashCode => Object.hash(runtimeType, pixels); + + @override + String toString() => '$FixedExtent(pixels: $pixels)'; } /// Manages the extent of a sheet. @@ -594,6 +600,7 @@ class SheetMetrics { /// The [FlutterView.devicePixelRatio] of the view that the sheet /// associated with this metrics object is drawn into. + // TODO: Move this to SheetContext. final double devicePixelRatio; double? get maybeMinPixels => switch ((maybeMinExtent, maybeContentSize)) { From 4aac917af7bb7f78ddc228e1cb671866c65925c5 Mon Sep 17 00:00:00 2001 From: fujidaiti Date: Fri, 13 Sep 2024 09:45:34 +0900 Subject: [PATCH 4/9] Add const keywords --- example/lib/tutorial/bottom_bar_visibility.dart | 4 ++-- example/lib/tutorial/sheet_content_scaffold.dart | 8 ++++---- example/lib/tutorial/sheet_physics.dart | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/example/lib/tutorial/bottom_bar_visibility.dart b/example/lib/tutorial/bottom_bar_visibility.dart index 9cf1fba4..6d756680 100644 --- a/example/lib/tutorial/bottom_bar_visibility.dart +++ b/example/lib/tutorial/bottom_bar_visibility.dart @@ -137,10 +137,10 @@ class _ExampleSheet extends StatelessWidget { const halfSize = Extent.proportional(0.5); const fullSize = Extent.proportional(1); - final multiStopPhysics = BouncingSheetPhysics( + const multiStopPhysics = BouncingSheetPhysics( parent: SnappingSheetPhysics( snappingBehavior: SnapToNearest( - snapTo: const [minSize, halfSize, fullSize], + snapTo: [minSize, halfSize, fullSize], ), ), ); diff --git a/example/lib/tutorial/sheet_content_scaffold.dart b/example/lib/tutorial/sheet_content_scaffold.dart index 5e2b3a2d..88550bc7 100644 --- a/example/lib/tutorial/sheet_content_scaffold.dart +++ b/example/lib/tutorial/sheet_content_scaffold.dart @@ -50,13 +50,13 @@ class _ExampleSheet extends StatelessWidget { ), ); - final physics = BouncingSheetPhysics( + const physics = BouncingSheetPhysics( parent: SnappingSheetPhysics( snappingBehavior: SnapToNearest( snapTo: [ - const Extent.proportional(0.2), - const Extent.proportional(0.5), - const Extent.proportional(1), + Extent.proportional(0.2), + Extent.proportional(0.5), + Extent.proportional(1), ], ), ), diff --git a/example/lib/tutorial/sheet_physics.dart b/example/lib/tutorial/sheet_physics.dart index 2082bd83..9440ca77 100644 --- a/example/lib/tutorial/sheet_physics.dart +++ b/example/lib/tutorial/sheet_physics.dart @@ -87,11 +87,11 @@ class _MySheet extends StatelessWidget { // - the extent at which ony (_halfwayFraction * 100)% of the content is visible, or // - the extent at which the entire content is visible. // Note that the "extent" is the visible height of the sheet. - final snappingPhysics = SnappingSheetPhysics( + const snappingPhysics = SnappingSheetPhysics( snappingBehavior: SnapToNearest( snapTo: [ - const Extent.proportional(_halfwayFraction), - const Extent.proportional(1), + Extent.proportional(_halfwayFraction), + Extent.proportional(1), ], ), // Tips: The above configuration can be replaced with a 'SnapToNearestEdge', @@ -104,9 +104,9 @@ class _MySheet extends StatelessWidget { _PhysicsKind.bouncing => const BouncingSheetPhysics(), _PhysicsKind.clampingSnapping => // Use 'parent' to combine multiple physics behaviors. - ClampingSheetPhysics(parent: snappingPhysics), + const ClampingSheetPhysics(parent: snappingPhysics), _PhysicsKind.bouncingSnapping => - BouncingSheetPhysics(parent: snappingPhysics), + const BouncingSheetPhysics(parent: snappingPhysics), }; } From 0f2758309acf44a2942387b17a7a3a366e032728 Mon Sep 17 00:00:00 2001 From: fujidaiti Date: Fri, 13 Sep 2024 09:46:08 +0900 Subject: [PATCH 5/9] Add tests --- lib/src/foundation/sheet_activity.dart | 1 + test/foundation/physics_test.dart | 6 +- test/foundation/sheet_activity_test.dart | 154 ++++++++++ test/src/matchers.dart | 14 + test/src/stubbing.dart | 6 + test/src/stubbing.mocks.dart | 353 +++++++++++++++++++---- 6 files changed, 475 insertions(+), 59 deletions(-) create mode 100644 test/src/matchers.dart diff --git a/lib/src/foundation/sheet_activity.dart b/lib/src/foundation/sheet_activity.dart index 28258d1a..d5b0d939 100644 --- a/lib/src/foundation/sheet_activity.dart +++ b/lib/src/foundation/sheet_activity.dart @@ -225,6 +225,7 @@ class AnimatedSheetActivity extends SheetActivity return; } + // TODO: Begin a SettlingSheetActivity. final oldEndPixels = destination.resolve(oldContentSize); final newEndPixels = destination.resolve(owner.metrics.contentSize); final progress = controller.value; diff --git a/test/foundation/physics_test.dart b/test/foundation/physics_test.dart index d0911158..e2a947f8 100644 --- a/test/foundation/physics_test.dart +++ b/test/foundation/physics_test.dart @@ -259,12 +259,14 @@ void main() { expect( physicsUnderTest.findSettledExtent(1000, _positionAtTopEdge), _referenceSheetMetrics.maxExtent, - reason: 'Should return the max extent if the position is at the upper bound', + reason: + 'Should return the max extent if the position is at the upper bound', ); expect( physicsUnderTest.findSettledExtent(1000, _positionAtBottomEdge), _referenceSheetMetrics.minExtent, - reason: 'Should return the min extent if the position is at the lower bound', + reason: + 'Should return the min extent if the position is at the lower bound', ); }); }); diff --git a/test/foundation/sheet_activity_test.dart b/test/foundation/sheet_activity_test.dart index 3082bdb9..c4f74abf 100644 --- a/test/foundation/sheet_activity_test.dart +++ b/test/foundation/sheet_activity_test.dart @@ -1,10 +1,12 @@ import 'package:flutter/animation.dart'; import 'package:flutter/painting.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:smooth_sheets/src/foundation/sheet_activity.dart'; import 'package:smooth_sheets/src/foundation/sheet_extent.dart'; +import '../src/matchers.dart'; import '../src/stubbing.dart'; class _TestAnimatedSheetActivity extends AnimatedSheetActivity { @@ -145,4 +147,156 @@ void main() { verify(owner.setPixels(850)); }); }); + + group('SettlingSheetActivity', () { + late MockSheetExtent owner; + late MockTicker internalTicker; + late TickerCallback? internalOnTickCallback; + + const initialMetrics = SheetMetrics( + pixels: 300, + minExtent: Extent.proportional(0.5), + maxExtent: Extent.proportional(1), + contentSize: Size(400, 600), + viewportSize: Size(400, 900), + viewportInsets: EdgeInsets.zero, + ); + + setUp(() { + owner = MockSheetExtent(); + internalTicker = MockTicker(); + final tickerProvider = MockTickerProvider(); + final context = MockSheetContext(); + when(context.vsync).thenReturn(tickerProvider); + when(tickerProvider.createTicker(any)).thenAnswer((invocation) { + internalOnTickCallback = + invocation.positionalArguments[0] as TickerCallback; + return internalTicker; + }); + when(owner.context).thenReturn(context); + }); + + tearDown(() { + internalOnTickCallback = null; + }); + + test('Create with velocity', () { + final activity = SettlingSheetActivity( + destination: const Extent.pixels(0), + velocity: 100, + ); + + expect(activity.destination, const Extent.pixels(0)); + expect(activity.duration, isNull); + expect(activity.velocity, 100); + expect(activity.shouldIgnorePointer, isFalse); + }); + + test('Create with duration', () { + final activity = SettlingSheetActivity.withDuration( + const Duration(milliseconds: 300), + destination: const Extent.pixels(0), + ); + + expect(activity.destination, const Extent.pixels(0)); + expect(activity.duration, const Duration(milliseconds: 300)); + expect(activity.shouldIgnorePointer, isFalse); + expect(() => activity.velocity, isNotInitialized); + }); + + test( + 'Velocity should be set when the activity is initialized', + () { + final activity = SettlingSheetActivity.withDuration( + const Duration(milliseconds: 300), + destination: const Extent.proportional(1), + ); + expect(() => activity.velocity, isNotInitialized); + + when(owner.metrics).thenReturn(initialMetrics); + activity.init(owner); + expect(activity.velocity, 1000); // (300pixels / 300ms) = 1000 pixels/s + }, + ); + + test('Progressively updates current position toward destination', () { + final activity = SettlingSheetActivity( + destination: const Extent.proportional(1), + velocity: 300, + ); + + activity.init(owner); + verify(internalTicker.start()); + + when(owner.metrics).thenReturn(initialMetrics); + internalOnTickCallback!(const Duration(milliseconds: 200)); + verify(owner.setPixels(360)); // 300 * 0.2 = 60 pixels in 200ms + + when(owner.metrics).thenReturn(initialMetrics.copyWith(pixels: 360)); + internalOnTickCallback!(const Duration(milliseconds: 400)); + verify(owner.setPixels(420)); // 300 * 0.2 = 60 pixels in 200ms + + when(owner.metrics).thenReturn(initialMetrics.copyWith(pixels: 420)); + internalOnTickCallback!(const Duration(milliseconds: 500)); + verify(owner.setPixels(450)); // 300 * 0.1 = 30 pixels in 100ms + + when(owner.metrics).thenReturn(initialMetrics.copyWith(pixels: 450)); + internalOnTickCallback!(const Duration(milliseconds: 800)); + verify(owner.setPixels(540)); // 300 * 0.3 = 90 pixels in 300ms + + when(owner.metrics).thenReturn(initialMetrics.copyWith(pixels: 540)); + internalOnTickCallback!(const Duration(milliseconds: 1000)); + verify(owner.setPixels(600)); // 300 * 0.2 = 60 pixels in 200ms + }); + + test( + 'Should start a ballistic activity when it reaches destination', + () { + final _ = SettlingSheetActivity( + destination: const Extent.proportional(1), + velocity: 300, + )..init(owner); + + when(owner.metrics).thenReturn(initialMetrics.copyWith(pixels: 540)); + internalOnTickCallback!(const Duration(milliseconds: 1000)); + verify(owner.goBallistic(0)); + }, + ); + + test('Should absorb viewport changes', () { + var metrics = initialMetrics; + when(owner.metrics).thenAnswer((_) => metrics); + when(owner.setPixels(any)).thenAnswer((invocation) { + final pixels = invocation.positionalArguments[0] as double; + metrics = metrics.copyWith(pixels: pixels); + }); + + final activity = SettlingSheetActivity.withDuration( + const Duration(milliseconds: 300), + destination: const Extent.proportional(1), + )..init(owner); + + expect(activity.velocity, 1000); // (300 pixels / 0.3s) = 1000 pixels/s + + internalOnTickCallback!(const Duration(milliseconds: 50)); + expect(metrics.pixels, 350); // 1000 * 0.05 = 50 pixels in 50ms + + // Show the on-screen keyboard. + metrics = metrics.copyWith( + viewportInsets: const EdgeInsets.only(bottom: 30), + ); + final oldViewportInsets = initialMetrics.viewportInsets; + final oldContentSize = initialMetrics.contentSize; + activity.didChangeViewportDimensions(null, oldViewportInsets); + activity.didChangeContentSize(oldContentSize); + activity.didFinalizeDimensions(oldContentSize, null, oldViewportInsets); + expect(metrics.pixels, 320, + reason: 'Visual position should not change when viewport changes.'); + expect(activity.velocity, 1120, // 280 pixels / 0.25s = 1120 pixels/s + reason: 'Velocity should be updated when viewport changes.'); + + internalOnTickCallback!(const Duration(milliseconds: 100)); + expect(metrics.pixels, 376); // 1120 * 0.05 = 56 pixels in 50ms + }); + }); } diff --git a/test/src/matchers.dart b/test/src/matchers.dart new file mode 100644 index 00000000..bbc5a4a4 --- /dev/null +++ b/test/src/matchers.dart @@ -0,0 +1,14 @@ +import 'package:flutter_test/flutter_test.dart'; + +Matcher throwsError({required String name}) => throwsA( + isA().having( + (e) => e.runtimeType.toString(), + 'runtimeType', + name, + ), + ); + +/// A matcher that checks if the error is a LateError. +/// +/// This is useful for verifying that a late field has not been initialized. +Matcher get isNotInitialized => throwsError(name: 'LateError'); diff --git a/test/src/stubbing.dart b/test/src/stubbing.dart index 22d072db..61e3b6e5 100644 --- a/test/src/stubbing.dart +++ b/test/src/stubbing.dart @@ -1,10 +1,16 @@ import 'package:flutter/animation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; import 'package:mockito/annotations.dart'; +import 'package:smooth_sheets/src/foundation/sheet_context.dart'; import 'package:smooth_sheets/src/foundation/sheet_extent.dart'; @GenerateNiceMocks([ MockSpec(), + MockSpec(), MockSpec(), MockSpec(), + MockSpec(), + MockSpec() ]) export 'stubbing.mocks.dart'; diff --git a/test/src/stubbing.mocks.dart b/test/src/stubbing.mocks.dart index 4067ee80..55eb1ccf 100644 --- a/test/src/stubbing.mocks.dart +++ b/test/src/stubbing.mocks.dart @@ -4,21 +4,23 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i8; -import 'dart:ui' as _i12; +import 'dart:ui' as _i14; import 'package:flutter/cupertino.dart' as _i7; +import 'package:flutter/foundation.dart' as _i9; import 'package:flutter/gestures.dart' as _i6; -import 'package:flutter/src/animation/curves.dart' as _i13; +import 'package:flutter/scheduler.dart' as _i10; +import 'package:flutter/src/animation/curves.dart' as _i15; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i14; +import 'package:mockito/src/dummies.dart' as _i16; import 'package:smooth_sheets/src/foundation/sheet_activity.dart' as _i5; import 'package:smooth_sheets/src/foundation/sheet_context.dart' as _i2; -import 'package:smooth_sheets/src/foundation/sheet_drag.dart' as _i9; +import 'package:smooth_sheets/src/foundation/sheet_drag.dart' as _i11; import 'package:smooth_sheets/src/foundation/sheet_extent.dart' as _i3; import 'package:smooth_sheets/src/foundation/sheet_gesture_tamperer.dart' - as _i11; + as _i13; import 'package:smooth_sheets/src/foundation/sheet_physics.dart' as _i4; -import 'package:smooth_sheets/src/foundation/sheet_status.dart' as _i10; +import 'package:smooth_sheets/src/foundation/sheet_status.dart' as _i12; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -94,8 +96,9 @@ class _FakeDrag_5 extends _i1.SmartFake implements _i6.Drag { ); } -class _FakeAnimation_6 extends _i1.SmartFake implements _i7.Animation { - _FakeAnimation_6( +class _FakeTickerProvider_6 extends _i1.SmartFake + implements _i7.TickerProvider { + _FakeTickerProvider_6( Object parent, Invocation parentInvocation, ) : super( @@ -104,8 +107,8 @@ class _FakeAnimation_6 extends _i1.SmartFake implements _i7.Animation { ); } -class _FakeTickerFuture_7 extends _i1.SmartFake implements _i7.TickerFuture { - _FakeTickerFuture_7( +class _FakeAnimation_7 extends _i1.SmartFake implements _i7.Animation { + _FakeAnimation_7( Object parent, Invocation parentInvocation, ) : super( @@ -114,8 +117,8 @@ class _FakeTickerFuture_7 extends _i1.SmartFake implements _i7.TickerFuture { ); } -class _FakeFuture_8 extends _i1.SmartFake implements _i8.Future { - _FakeFuture_8( +class _FakeTickerFuture_8 extends _i1.SmartFake implements _i7.TickerFuture { + _FakeTickerFuture_8( Object parent, Invocation parentInvocation, ) : super( @@ -124,6 +127,47 @@ class _FakeFuture_8 extends _i1.SmartFake implements _i8.Future { ); } +class _FakeFuture_9 extends _i1.SmartFake implements _i8.Future { + _FakeFuture_9( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeDiagnosticsNode_10 extends _i1.SmartFake + implements _i7.DiagnosticsNode { + _FakeDiagnosticsNode_10( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); + + @override + String toString({ + _i9.TextTreeConfiguration? parentConfiguration, + _i7.DiagnosticLevel? minLevel = _i7.DiagnosticLevel.info, + }) => + super.toString(); +} + +class _FakeTicker_11 extends _i1.SmartFake implements _i10.Ticker { + _FakeTicker_11( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); + + @override + String toString({bool? debugIncludeStack = false}) => super.toString(); +} + /// A class which mocks [SheetExtent]. /// /// See the documentation for Mockito's code generation for more information. @@ -142,7 +186,7 @@ class MockSheetExtent extends _i1.Mock implements _i3.SheetExtent { ) as _i2.SheetContext); @override - set currentDrag(_i9.SheetDragController? _currentDrag) => super.noSuchMethod( + set currentDrag(_i11.SheetDragController? _currentDrag) => super.noSuchMethod( Invocation.setter( #currentDrag, _currentDrag, @@ -164,11 +208,11 @@ class MockSheetExtent extends _i1.Mock implements _i3.SheetExtent { ) as _i3.SheetMetrics); @override - _i10.SheetStatus get status => (super.noSuchMethod( + _i12.SheetStatus get status => (super.noSuchMethod( Invocation.getter(#status), - returnValue: _i10.SheetStatus.stable, - returnValueForMissingStub: _i10.SheetStatus.stable, - ) as _i10.SheetStatus); + returnValue: _i12.SheetStatus.stable, + returnValueForMissingStub: _i12.SheetStatus.stable, + ) as _i12.SheetStatus); @override _i3.Extent get minExtent => (super.noSuchMethod( @@ -252,7 +296,7 @@ class MockSheetExtent extends _i1.Mock implements _i3.SheetExtent { ); @override - void updateGestureTamperer(_i11.SheetGestureTamperer? gestureTamperer) => + void updateGestureTamperer(_i13.SheetGestureTamperer? gestureTamperer) => super.noSuchMethod( Invocation.method( #updateGestureTamperer, @@ -271,7 +315,7 @@ class MockSheetExtent extends _i1.Mock implements _i3.SheetExtent { ); @override - void applyNewContentSize(_i12.Size? contentSize) => super.noSuchMethod( + void applyNewContentSize(_i14.Size? contentSize) => super.noSuchMethod( Invocation.method( #applyNewContentSize, [contentSize], @@ -281,7 +325,7 @@ class MockSheetExtent extends _i1.Mock implements _i3.SheetExtent { @override void applyNewViewportDimensions( - _i12.Size? size, + _i14.Size? size, _i7.EdgeInsets? insets, ) => super.noSuchMethod( @@ -387,7 +431,7 @@ class MockSheetExtent extends _i1.Mock implements _i3.SheetExtent { @override _i6.Drag drag( _i7.DragStartDetails? details, - _i12.VoidCallback? dragCancelCallback, + _i14.VoidCallback? dragCancelCallback, ) => (super.noSuchMethod( Invocation.method( @@ -475,7 +519,7 @@ class MockSheetExtent extends _i1.Mock implements _i3.SheetExtent { ); @override - void didDragStart(_i9.SheetDragStartDetails? details) => super.noSuchMethod( + void didDragStart(_i11.SheetDragStartDetails? details) => super.noSuchMethod( Invocation.method( #didDragStart, [details], @@ -484,7 +528,7 @@ class MockSheetExtent extends _i1.Mock implements _i3.SheetExtent { ); @override - void didDragEnd(_i9.SheetDragEndDetails? details) => super.noSuchMethod( + void didDragEnd(_i11.SheetDragEndDetails? details) => super.noSuchMethod( Invocation.method( #didDragEnd, [details], @@ -493,7 +537,7 @@ class MockSheetExtent extends _i1.Mock implements _i3.SheetExtent { ); @override - void didDragUpdateMetrics(_i9.SheetDragUpdateDetails? details) => + void didDragUpdateMetrics(_i11.SheetDragUpdateDetails? details) => super.noSuchMethod( Invocation.method( #didDragUpdateMetrics, @@ -521,7 +565,7 @@ class MockSheetExtent extends _i1.Mock implements _i3.SheetExtent { ); @override - void addListener(_i12.VoidCallback? listener) => super.noSuchMethod( + void addListener(_i14.VoidCallback? listener) => super.noSuchMethod( Invocation.method( #addListener, [listener], @@ -530,7 +574,7 @@ class MockSheetExtent extends _i1.Mock implements _i3.SheetExtent { ); @override - void removeListener(_i12.VoidCallback? listener) => super.noSuchMethod( + void removeListener(_i14.VoidCallback? listener) => super.noSuchMethod( Invocation.method( #removeListener, [listener], @@ -548,6 +592,31 @@ class MockSheetExtent extends _i1.Mock implements _i3.SheetExtent { ); } +/// A class which mocks [SheetContext]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSheetContext extends _i1.Mock implements _i2.SheetContext { + @override + _i7.TickerProvider get vsync => (super.noSuchMethod( + Invocation.getter(#vsync), + returnValue: _FakeTickerProvider_6( + this, + Invocation.getter(#vsync), + ), + returnValueForMissingStub: _FakeTickerProvider_6( + this, + Invocation.getter(#vsync), + ), + ) as _i7.TickerProvider); + + @override + double get devicePixelRatio => (super.noSuchMethod( + Invocation.getter(#devicePixelRatio), + returnValue: 0.0, + returnValueForMissingStub: 0.0, + ) as double); +} + /// A class which mocks [AnimationController]. /// /// See the documentation for Mockito's code generation for more information. @@ -595,11 +664,11 @@ class MockAnimationController extends _i1.Mock @override _i7.Animation get view => (super.noSuchMethod( Invocation.getter(#view), - returnValue: _FakeAnimation_6( + returnValue: _FakeAnimation_7( this, Invocation.getter(#view), ), - returnValueForMissingStub: _FakeAnimation_6( + returnValueForMissingStub: _FakeAnimation_7( this, Invocation.getter(#view), ), @@ -681,7 +750,7 @@ class MockAnimationController extends _i1.Mock [], {#from: from}, ), - returnValue: _FakeTickerFuture_7( + returnValue: _FakeTickerFuture_8( this, Invocation.method( #forward, @@ -689,7 +758,7 @@ class MockAnimationController extends _i1.Mock {#from: from}, ), ), - returnValueForMissingStub: _FakeTickerFuture_7( + returnValueForMissingStub: _FakeTickerFuture_8( this, Invocation.method( #forward, @@ -706,7 +775,7 @@ class MockAnimationController extends _i1.Mock [], {#from: from}, ), - returnValue: _FakeTickerFuture_7( + returnValue: _FakeTickerFuture_8( this, Invocation.method( #reverse, @@ -714,7 +783,7 @@ class MockAnimationController extends _i1.Mock {#from: from}, ), ), - returnValueForMissingStub: _FakeTickerFuture_7( + returnValueForMissingStub: _FakeTickerFuture_8( this, Invocation.method( #reverse, @@ -728,7 +797,7 @@ class MockAnimationController extends _i1.Mock _i7.TickerFuture animateTo( double? target, { Duration? duration, - _i7.Curve? curve = _i13.Curves.linear, + _i7.Curve? curve = _i15.Curves.linear, }) => (super.noSuchMethod( Invocation.method( @@ -739,7 +808,7 @@ class MockAnimationController extends _i1.Mock #curve: curve, }, ), - returnValue: _FakeTickerFuture_7( + returnValue: _FakeTickerFuture_8( this, Invocation.method( #animateTo, @@ -750,7 +819,7 @@ class MockAnimationController extends _i1.Mock }, ), ), - returnValueForMissingStub: _FakeTickerFuture_7( + returnValueForMissingStub: _FakeTickerFuture_8( this, Invocation.method( #animateTo, @@ -767,7 +836,7 @@ class MockAnimationController extends _i1.Mock _i7.TickerFuture animateBack( double? target, { Duration? duration, - _i7.Curve? curve = _i13.Curves.linear, + _i7.Curve? curve = _i15.Curves.linear, }) => (super.noSuchMethod( Invocation.method( @@ -778,7 +847,7 @@ class MockAnimationController extends _i1.Mock #curve: curve, }, ), - returnValue: _FakeTickerFuture_7( + returnValue: _FakeTickerFuture_8( this, Invocation.method( #animateBack, @@ -789,7 +858,7 @@ class MockAnimationController extends _i1.Mock }, ), ), - returnValueForMissingStub: _FakeTickerFuture_7( + returnValueForMissingStub: _FakeTickerFuture_8( this, Invocation.method( #animateBack, @@ -820,7 +889,7 @@ class MockAnimationController extends _i1.Mock #period: period, }, ), - returnValue: _FakeTickerFuture_7( + returnValue: _FakeTickerFuture_8( this, Invocation.method( #repeat, @@ -833,7 +902,7 @@ class MockAnimationController extends _i1.Mock }, ), ), - returnValueForMissingStub: _FakeTickerFuture_7( + returnValueForMissingStub: _FakeTickerFuture_8( this, Invocation.method( #repeat, @@ -864,7 +933,7 @@ class MockAnimationController extends _i1.Mock #animationBehavior: animationBehavior, }, ), - returnValue: _FakeTickerFuture_7( + returnValue: _FakeTickerFuture_8( this, Invocation.method( #fling, @@ -876,7 +945,7 @@ class MockAnimationController extends _i1.Mock }, ), ), - returnValueForMissingStub: _FakeTickerFuture_7( + returnValueForMissingStub: _FakeTickerFuture_8( this, Invocation.method( #fling, @@ -897,14 +966,14 @@ class MockAnimationController extends _i1.Mock #animateWith, [simulation], ), - returnValue: _FakeTickerFuture_7( + returnValue: _FakeTickerFuture_8( this, Invocation.method( #animateWith, [simulation], ), ), - returnValueForMissingStub: _FakeTickerFuture_7( + returnValueForMissingStub: _FakeTickerFuture_8( this, Invocation.method( #animateWith, @@ -938,14 +1007,14 @@ class MockAnimationController extends _i1.Mock #toStringDetails, [], ), - returnValue: _i14.dummyValue( + returnValue: _i16.dummyValue( this, Invocation.method( #toStringDetails, [], ), ), - returnValueForMissingStub: _i14.dummyValue( + returnValueForMissingStub: _i16.dummyValue( this, Invocation.method( #toStringDetails, @@ -955,7 +1024,7 @@ class MockAnimationController extends _i1.Mock ) as String); @override - void addListener(_i12.VoidCallback? listener) => super.noSuchMethod( + void addListener(_i14.VoidCallback? listener) => super.noSuchMethod( Invocation.method( #addListener, [listener], @@ -964,7 +1033,7 @@ class MockAnimationController extends _i1.Mock ); @override - void removeListener(_i12.VoidCallback? listener) => super.noSuchMethod( + void removeListener(_i14.VoidCallback? listener) => super.noSuchMethod( Invocation.method( #removeListener, [listener], @@ -998,14 +1067,14 @@ class MockAnimationController extends _i1.Mock #drive, [child], ), - returnValue: _FakeAnimation_6( + returnValue: _FakeAnimation_7( this, Invocation.method( #drive, [child], ), ), - returnValueForMissingStub: _FakeAnimation_6( + returnValueForMissingStub: _FakeAnimation_7( this, Invocation.method( #drive, @@ -1081,7 +1150,7 @@ class MockTickerFuture extends _i1.Mock implements _i7.TickerFuture { ) as _i8.Future); @override - void whenCompleteOrCancel(_i12.VoidCallback? callback) => super.noSuchMethod( + void whenCompleteOrCancel(_i14.VoidCallback? callback) => super.noSuchMethod( Invocation.method( #whenCompleteOrCancel, [callback], @@ -1125,8 +1194,8 @@ class MockTickerFuture extends _i1.Mock implements _i7.TickerFuture { [onValue], {#onError: onError}, ), - returnValue: _i14.ifNotNull( - _i14.dummyValueOrNull( + returnValue: _i16.ifNotNull( + _i16.dummyValueOrNull( this, Invocation.method( #then, @@ -1136,7 +1205,7 @@ class MockTickerFuture extends _i1.Mock implements _i7.TickerFuture { ), (R v) => _i8.Future.value(v), ) ?? - _FakeFuture_8( + _FakeFuture_9( this, Invocation.method( #then, @@ -1144,8 +1213,8 @@ class MockTickerFuture extends _i1.Mock implements _i7.TickerFuture { {#onError: onError}, ), ), - returnValueForMissingStub: _i14.ifNotNull( - _i14.dummyValueOrNull( + returnValueForMissingStub: _i16.ifNotNull( + _i16.dummyValueOrNull( this, Invocation.method( #then, @@ -1155,7 +1224,7 @@ class MockTickerFuture extends _i1.Mock implements _i7.TickerFuture { ), (R v) => _i8.Future.value(v), ) ?? - _FakeFuture_8( + _FakeFuture_9( this, Invocation.method( #then, @@ -1191,3 +1260,173 @@ class MockTickerFuture extends _i1.Mock implements _i7.TickerFuture { returnValueForMissingStub: _i8.Future.value(), ) as _i8.Future); } + +/// A class which mocks [Ticker]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTicker extends _i1.Mock implements _i10.Ticker { + @override + bool get muted => (super.noSuchMethod( + Invocation.getter(#muted), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + set muted(bool? value) => super.noSuchMethod( + Invocation.setter( + #muted, + value, + ), + returnValueForMissingStub: null, + ); + + @override + bool get isTicking => (super.noSuchMethod( + Invocation.getter(#isTicking), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + bool get isActive => (super.noSuchMethod( + Invocation.getter(#isActive), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + bool get scheduled => (super.noSuchMethod( + Invocation.getter(#scheduled), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + bool get shouldScheduleTick => (super.noSuchMethod( + Invocation.getter(#shouldScheduleTick), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + _i7.TickerFuture start() => (super.noSuchMethod( + Invocation.method( + #start, + [], + ), + returnValue: _FakeTickerFuture_8( + this, + Invocation.method( + #start, + [], + ), + ), + returnValueForMissingStub: _FakeTickerFuture_8( + this, + Invocation.method( + #start, + [], + ), + ), + ) as _i7.TickerFuture); + + @override + _i7.DiagnosticsNode describeForError(String? name) => (super.noSuchMethod( + Invocation.method( + #describeForError, + [name], + ), + returnValue: _FakeDiagnosticsNode_10( + this, + Invocation.method( + #describeForError, + [name], + ), + ), + returnValueForMissingStub: _FakeDiagnosticsNode_10( + this, + Invocation.method( + #describeForError, + [name], + ), + ), + ) as _i7.DiagnosticsNode); + + @override + void stop({bool? canceled = false}) => super.noSuchMethod( + Invocation.method( + #stop, + [], + {#canceled: canceled}, + ), + returnValueForMissingStub: null, + ); + + @override + void scheduleTick({bool? rescheduling = false}) => super.noSuchMethod( + Invocation.method( + #scheduleTick, + [], + {#rescheduling: rescheduling}, + ), + returnValueForMissingStub: null, + ); + + @override + void unscheduleTick() => super.noSuchMethod( + Invocation.method( + #unscheduleTick, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void absorbTicker(_i10.Ticker? originalTicker) => super.noSuchMethod( + Invocation.method( + #absorbTicker, + [originalTicker], + ), + returnValueForMissingStub: null, + ); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); + + @override + String toString({bool? debugIncludeStack = false}) => super.toString(); +} + +/// A class which mocks [TickerProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTickerProvider extends _i1.Mock implements _i7.TickerProvider { + @override + _i10.Ticker createTicker(_i10.TickerCallback? onTick) => (super.noSuchMethod( + Invocation.method( + #createTicker, + [onTick], + ), + returnValue: _FakeTicker_11( + this, + Invocation.method( + #createTicker, + [onTick], + ), + ), + returnValueForMissingStub: _FakeTicker_11( + this, + Invocation.method( + #createTicker, + [onTick], + ), + ), + ) as _i10.Ticker); +} From 817b90094ed7196ccb4546e8dfc9bc4e0c43cd52 Mon Sep 17 00:00:00 2001 From: fujidaiti Date: Fri, 13 Sep 2024 20:35:15 +0900 Subject: [PATCH 6/9] Add doc comments --- lib/src/foundation/sheet_activity.dart | 43 +++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/lib/src/foundation/sheet_activity.dart b/lib/src/foundation/sheet_activity.dart index d5b0d939..59d38524 100644 --- a/lib/src/foundation/sheet_activity.dart +++ b/lib/src/foundation/sheet_activity.dart @@ -8,6 +8,7 @@ import 'package:meta/meta.dart'; import 'sheet_drag.dart'; import 'sheet_extent.dart'; +import 'sheet_physics.dart'; import 'sheet_status.dart'; @internal @@ -211,6 +212,7 @@ class AnimatedSheetActivity extends SheetActivity Size? oldViewportSize, EdgeInsets? oldViewportInsets, ) { + // TODO: DRY with other activities. // Appends the delta of the bottom inset (typically the keyboard height) // to keep the visual sheet position unchanged. final newInsets = owner.metrics.viewportInsets; @@ -225,7 +227,7 @@ class AnimatedSheetActivity extends SheetActivity return; } - // TODO: Begin a SettlingSheetActivity. + // TODO: Remove the following logic and start a settling activity instead. final oldEndPixels = destination.resolve(oldContentSize); final newEndPixels = destination.resolve(owner.metrics.contentSize); final progress = controller.value; @@ -324,7 +326,23 @@ class BallisticSheetActivity extends SheetActivity } } +/// A [SheetActivity] that performs a settling motion in response to changes +/// in the viewport dimensions or content size. +/// +/// This activity animates the sheet position to the [destination] with a +/// constant [velocity] until the destination is reached. Optionally, the +/// animation [duration] can be specified to explicitly control the time it +/// takes to reach the [destination]. In this case, the [velocity] is determined +/// based on the distance to the [destination] and the specified [duration]. +/// +/// When the concrete value of the [destination] changes due to viewport +/// metrics or content size changes, and the [duration] is specified, +/// the [velocity] is recalculated to ensure the animation duration remains +/// consistent. +@internal class SettlingSheetActivity extends SheetActivity { + /// Creates a settling activity that animates the sheet position to the + /// [destination] with a constant [velocity]. SettlingSheetActivity({ required this.destination, required double velocity, @@ -332,13 +350,22 @@ class SettlingSheetActivity extends SheetActivity { _velocity = velocity, duration = null; + /// Creates a settling activity that animates the sheet position to the + /// [destination] over the specified [duration]. SettlingSheetActivity.withDuration( Duration this.duration, { required this.destination, }) : assert(duration > Duration.zero); + /// The amount of time the animation should take to reach the destination. + /// + /// If `null`, the animation lasts until the destination is reached + /// or this activity is disposed. final Duration? duration; + + /// The destination position to which the sheet should settle. final Extent destination; + late final Ticker _ticker; /// The amount of time that has passed between the time the animation @@ -359,6 +386,12 @@ class SettlingSheetActivity extends SheetActivity { _invalidateVelocity(); } + /// Updates the sheet position toward the destination based on the current + /// [_velocity] and the time elapsed since the last frame. + /// + /// If the destination is reached, a ballistic activity is started with + /// zero velocity to ensure consistency between the settled position + /// and the current [SheetPhysics]. void _tick(Duration elapsedDuration) { final elapsedFrameTime = (elapsedDuration - _elapsedDuration).inMicroseconds / @@ -407,6 +440,14 @@ class SettlingSheetActivity extends SheetActivity { super.dispose(); } + /// Updates [_velocity] based on the remaining time and distance to the + /// destination position. + /// + /// Make sure to call this method on initialization and whenever the + /// destination changes due to the viewport size or content size changing. + /// + /// If the animation [duration] is not specified, this method preserves the + /// current velocity. void _invalidateVelocity() { if (duration case final duration?) { final remainingSeconds = (duration - _elapsedDuration).inMicroseconds / From f9fe2341b8ca2deddff0a7cc2f99e8f46ef96220 Mon Sep 17 00:00:00 2001 From: fujidaiti Date: Fri, 13 Sep 2024 23:30:18 +0900 Subject: [PATCH 7/9] Add todos --- lib/src/foundation/sheet_activity.dart | 9 +++++++++ lib/src/foundation/sheet_extent.dart | 1 + 2 files changed, 10 insertions(+) diff --git a/lib/src/foundation/sheet_activity.dart b/lib/src/foundation/sheet_activity.dart index 59d38524..9a4e5167 100644 --- a/lib/src/foundation/sheet_activity.dart +++ b/lib/src/foundation/sheet_activity.dart @@ -465,6 +465,15 @@ class SettlingSheetActivity extends SheetActivity { class IdleSheetActivity extends SheetActivity { @override SheetStatus get status => SheetStatus.stable; + + // TODO: Start a settling activity if the keyboard animation is running. + // @override + // void didFinalizeDimensions( + // Size? oldContentSize, + // Size? oldViewportSize, + // EdgeInsets? oldViewportInsets, + // ) { + // } } @internal diff --git a/lib/src/foundation/sheet_extent.dart b/lib/src/foundation/sheet_extent.dart index 77a0f172..986a0f40 100644 --- a/lib/src/foundation/sheet_extent.dart +++ b/lib/src/foundation/sheet_extent.dart @@ -416,6 +416,7 @@ abstract class SheetExtent extends ChangeNotifier beginActivity(BallisticSheetActivity(simulation: simulation)); } + // TODO: Change the signature to `void settle(Extent settledPosition, [Duration? duration])`. void settle() { assert(metrics.hasDimensions); final simulation = physics.createSettlingSimulation(metrics); From e9b51048914298cdfa712374727d02ade9e90cc2 Mon Sep 17 00:00:00 2001 From: fujidaiti Date: Sat, 14 Sep 2024 14:29:20 +0900 Subject: [PATCH 8/9] Decrease settling animation duration --- lib/src/foundation/sheet_activity.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/foundation/sheet_activity.dart b/lib/src/foundation/sheet_activity.dart index 9a4e5167..90e2e1f5 100644 --- a/lib/src/foundation/sheet_activity.dart +++ b/lib/src/foundation/sheet_activity.dart @@ -318,7 +318,7 @@ class BallisticSheetActivity extends SheetActivity when detent.resolve(owner.metrics.contentSize) != newPixels) { owner.beginActivity( SettlingSheetActivity.withDuration( - const Duration(milliseconds: 200), + const Duration(milliseconds: 150), destination: detent, ), ); From 935117dc9aa7bc3de7c0978d89724b6ab0a31633 Mon Sep 17 00:00:00 2001 From: fujidaiti Date: Sat, 14 Sep 2024 15:10:08 +0900 Subject: [PATCH 9/9] Add migration guide --- migrations/migration-guide-0.10.x.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/migrations/migration-guide-0.10.x.md b/migrations/migration-guide-0.10.x.md index 5fa73473..0ede3706 100644 --- a/migrations/migration-guide-0.10.x.md +++ b/migrations/migration-guide-0.10.x.md @@ -3,3 +3,7 @@ ## Breaking changes in SheetMetrics `double? minPixels` and `double? maxPixels` parameters of the constructor and `copyWith` method have been replaced with `Extent? minExtent` and `Extent? maxExtent` respectively. The `minPixels` and `maxPixels` getters are still available in the new version. + +## Breaking change in SnappingSheetBehavior + +`findSnapPixels` method has been removed. Use `findSnapExtent` instead.