diff --git a/packages/flutter/lib/src/animation/animation_controller.dart b/packages/flutter/lib/src/animation/animation_controller.dart index c946b447d6..d6592f75f0 100644 --- a/packages/flutter/lib/src/animation/animation_controller.dart +++ b/packages/flutter/lib/src/animation/animation_controller.dart @@ -383,8 +383,8 @@ class AnimationController extends Animation /// * [stop], which aborts the animation without changing its value or status /// and without dispatching any notifications other than completing or /// canceling the [TickerFuture]. - /// * [forward], [reverse], [animateTo], [animateWith], [fling], and [repeat], - /// which start the animation controller. + /// * [forward], [reverse], [animateTo], [animateWith], [animateBackWith], + /// [fling], and [repeat], which start the animation controller. set value(double newValue) { stop(); _internalSetValue(newValue); @@ -802,6 +802,7 @@ class AnimationController extends Animation /// Drives the animation according to the given simulation. /// + /// {@template flutter.animation.AnimationController.animateWith} /// The values from the simulation are clamped to the [lowerBound] and /// [upperBound]. To avoid this, consider creating the [AnimationController] /// using the [AnimationController.unbounded] constructor. @@ -811,9 +812,15 @@ class AnimationController extends Animation /// The most recently returned [TickerFuture], if any, is marked as having been /// canceled, meaning the future never completes and its [TickerFuture.orCancel] /// derivative future completes with a [TickerCanceled] error. + /// {@endtemplate} /// /// The [status] is always [AnimationStatus.forward] for the entire duration /// of the simulation. + /// + /// See also: + /// + /// * [animateBackWith], which is like this method but the status is always + /// [AnimationStatus.reverse]. TickerFuture animateWith(Simulation simulation) { assert( _ticker != null, @@ -825,6 +832,29 @@ class AnimationController extends Animation return _startSimulation(simulation); } + /// Drives the animation according to the given simulation with a [status] of + /// [AnimationStatus.reverse]. + /// + /// {@macro flutter.animation.AnimationController.animateWith} + /// + /// The [status] is always [AnimationStatus.reverse] for the entire duration + /// of the simulation. + /// + /// See also: + /// + /// * [animateWith], which is like this method but the status is always + /// [AnimationStatus.forward]. + TickerFuture animateBackWith(Simulation simulation) { + assert( + _ticker != null, + 'AnimationController.animateWith() called after AnimationController.dispose()\n' + 'AnimationController methods should not be used after calling dispose.', + ); + stop(); + _direction = _AnimationDirection.reverse; + return _startSimulation(simulation); + } + TickerFuture _startSimulation(Simulation simulation) { assert(!isAnimating); _simulation = simulation; diff --git a/packages/flutter/lib/src/cupertino/route.dart b/packages/flutter/lib/src/cupertino/route.dart index ae68440c21..0b40b4aa35 100644 --- a/packages/flutter/lib/src/cupertino/route.dart +++ b/packages/flutter/lib/src/cupertino/route.dart @@ -20,6 +20,7 @@ import 'dart:ui' show ImageFilter; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; +import 'package:flutter/physics.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; @@ -1065,6 +1066,30 @@ class _CupertinoEdgeShadowPainter extends BoxPainter { } } +// The stiffness used by dialogs and action sheets. +// +// The stiffness value is obtained by examining the properties of +// `CASpringAnimation` in Xcode. The damping value is derived similarly, with +// additional precision calculated based on `_kStandardStiffness` to ensure a +// damping ratio of 1 (critically damped): damping = 2 * sqrt(stiffness) +const double _kStandardStiffness = 522.35; +const double _kStandardDamping = 45.7099552; +const SpringDescription _kStandardSpring = SpringDescription( + mass: 1, + stiffness: _kStandardStiffness, + damping: _kStandardDamping, +); +// The iOS spring animation duration is 0.404 seconds, based on the properties +// of `CASpringAnimation` in Xcode. At this point, the spring's position +// `x(0.404)` is approximately 0.9990000, suggesting that iOS uses a position +// tolerance of 1e-3 (matching the default `_epsilonDefault` value). +// +// However, the spring's velocity `dx(0.404)` is about 0.02, indicating that iOS +// may not consider velocity when determining the animation's end condition. To +// account for this, a larger velocity tolerance is applied here for added +// safety. +const Tolerance _kStandardTolerance = Tolerance(velocity: 0.03); + /// A route that shows a modal iOS-style popup that slides up from the /// bottom of the screen. /// @@ -1144,29 +1169,21 @@ class CupertinoModalPopupRoute extends PopupRoute { @override Duration get transitionDuration => _kModalPopupTransitionDuration; - CurvedAnimation? _animation; - - late Tween _offsetTween; - /// {@macro flutter.widgets.DisplayFeatureSubScreen.anchorPoint} final Offset? anchorPoint; @override - Animation createAnimation() { - assert(_animation == null); - _animation = CurvedAnimation( - parent: super.createAnimation(), - - // These curves were initially measured from native iOS horizontal page - // route animations and seemed to be a good match here as well. - curve: Curves.linearToEaseOut, - reverseCurve: Curves.linearToEaseOut.flipped, + Simulation createSimulation({ required bool forward }) { + assert(!debugTransitionCompleted(), 'Cannot reuse a $runtimeType after disposing it.'); + final double end = forward ? 1.0 : 0.0; + return SpringSimulation( + _kStandardSpring, + controller!.value, + end, + 0, + tolerance: _kStandardTolerance, + snapToEnd: true, ); - _offsetTween = Tween( - begin: const Offset(0.0, 1.0), - end: Offset.zero, - ); - return _animation!; } @override @@ -1185,17 +1202,16 @@ class CupertinoModalPopupRoute extends PopupRoute { return Align( alignment: Alignment.bottomCenter, child: FractionalTranslation( - translation: _offsetTween.evaluate(_animation!), + translation: _offsetTween.evaluate(animation), child: child, ), ); } - @override - void dispose() { - _animation?.dispose(); - super.dispose(); - } + static final Tween _offsetTween = Tween( + begin: const Offset(0.0, 1.0), + end: Offset.zero, + ); } /// Shows a modal iOS-style popup that slides up from the bottom of the screen. @@ -1286,12 +1302,6 @@ Future showCupertinoModalPopup({ ); } -// The curve and initial scale values were mostly eyeballed from iOS, however -// they reuse the same animation curve that was modeled after native page -// transitions. -final Animatable _dialogScaleTween = Tween(begin: 1.3, end: 1.0) - .chain(CurveTween(curve: Curves.linearToEaseOut)); - Widget _buildCupertinoDialogTransitions(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { return child; } @@ -1439,33 +1449,36 @@ class CupertinoDialogRoute extends RawDialogRoute { CurvedAnimation? _fadeAnimation; @override - Widget buildTransitions(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + Simulation createSimulation({ required bool forward }) { + assert(!debugTransitionCompleted(), 'Cannot reuse a $runtimeType after disposing it.'); + final double end = forward ? 1.0 : 0.0; + return SpringSimulation( + _kStandardSpring, + controller!.value, + end, + 0, + tolerance: _kStandardTolerance, + snapToEnd: true, + ); + } + @override + Widget buildTransitions(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { if (transitionBuilder != null) { return super.buildTransitions(context, animation, secondaryAnimation, child); } - if (_fadeAnimation?.parent != animation) { - _fadeAnimation?.dispose(); - _fadeAnimation = CurvedAnimation( - parent: animation, - curve: Curves.easeInOut, - ); - } - - final CurvedAnimation fadeAnimation = _fadeAnimation!; - if (animation.status == AnimationStatus.reverse) { return FadeTransition( - opacity: fadeAnimation, - child: super.buildTransitions(context, animation, secondaryAnimation, child), + opacity: animation, + child: child, ); } return FadeTransition( - opacity: fadeAnimation, + opacity: animation, child: ScaleTransition( scale: animation.drive(_dialogScaleTween), - child: super.buildTransitions(context, animation, secondaryAnimation, child), + child: child, ), ); } @@ -1475,4 +1488,9 @@ class CupertinoDialogRoute extends RawDialogRoute { _fadeAnimation?.dispose(); super.dispose(); } + + // The curve and initial scale values were mostly eyeballed from iOS, however + // they reuse the same animation curve that was modeled after native page + // transitions. + static final Tween _dialogScaleTween = Tween(begin: 1.3, end: 1.0); } diff --git a/packages/flutter/lib/src/physics/spring_simulation.dart b/packages/flutter/lib/src/physics/spring_simulation.dart index 2faa724b87..1a918ba4e3 100644 --- a/packages/flutter/lib/src/physics/spring_simulation.dart +++ b/packages/flutter/lib/src/physics/spring_simulation.dart @@ -139,17 +139,25 @@ class SpringSimulation extends Simulation { /// The units for the velocity are L/T, where L is the aforementioned /// arbitrary unit of length, and T is the time unit used for driving the /// [SpringSimulation]. + /// + /// If `snapToEnd` is true, [x] will be set to `end` and [dx] to 0 when + /// [isDone] returns true. This is useful for transitions that require the + /// simulation to stop exactly at the end value, since the spring may not + /// naturally reach the target precisely. Defaults to false. SpringSimulation( SpringDescription spring, double start, double end, double velocity, { + bool snapToEnd = false, super.tolerance, }) : _endPosition = end, - _solution = _SpringSolution(spring, start - end, velocity); + _solution = _SpringSolution(spring, start - end, velocity), + _snapToEnd = snapToEnd; final double _endPosition; final _SpringSolution _solution; + final bool _snapToEnd; /// The kind of spring being simulated, for debugging purposes. /// @@ -158,10 +166,22 @@ class SpringSimulation extends Simulation { SpringType get type => _solution.type; @override - double x(double time) => _endPosition + _solution.x(time); + double x(double time) { + if (_snapToEnd && isDone(time)) { + return _endPosition; + } else { + return _endPosition + _solution.x(time); + } + } @override - double dx(double time) => _solution.dx(time); + double dx(double time) { + if (_snapToEnd && isDone(time)) { + return 0; + } else { + return _solution.dx(time); + } + } @override bool isDone(double time) { diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart index 5cce284980..86764aa0ad 100644 --- a/packages/flutter/lib/src/widgets/routes.dart +++ b/packages/flutter/lib/src/widgets/routes.dart @@ -213,6 +213,23 @@ abstract class TransitionRoute extends OverlayRoute implements PredictiveB /// It defaults to `true`. bool willDisposeAnimationController = true; + /// Returns true if the transition has completed. + /// + /// It is equivalent to whether the future returned by [completed] has + /// completed. + /// + /// This method only works if assert is enabled. Otherwise it always returns + /// false. + @protected + bool debugTransitionCompleted() { + bool disposed = false; + assert(() { + disposed = _transitionCompleter.isCompleted; + return true; + }()); + return disposed; + } + /// Called to create the animation controller that will drive the transitions to /// this route from the previous one, and back to the previous route from this /// one. @@ -220,10 +237,9 @@ abstract class TransitionRoute extends OverlayRoute implements PredictiveB /// The returned controller will be disposed by [AnimationController.dispose] /// if the [willDisposeAnimationController] is `true`. AnimationController createAnimationController() { - assert(!_transitionCompleter.isCompleted, 'Cannot reuse a $runtimeType after disposing it.'); + assert(!debugTransitionCompleted(), 'Cannot reuse a $runtimeType after disposing it.'); final Duration duration = transitionDuration; final Duration reverseDuration = reverseTransitionDuration; - assert(duration >= Duration.zero); return AnimationController( duration: duration, reverseDuration: reverseDuration, @@ -236,11 +252,44 @@ abstract class TransitionRoute extends OverlayRoute implements PredictiveB /// the transition controlled by the animation controller created by /// [createAnimationController()]. Animation createAnimation() { - assert(!_transitionCompleter.isCompleted, 'Cannot reuse a $runtimeType after disposing it.'); + assert(!debugTransitionCompleted(), 'Cannot reuse a $runtimeType after disposing it.'); assert(_controller != null); return _controller!.view; } + Simulation? _simulation; + + /// Creates the simulation that drives the transition animation for this route. + /// + /// By default, this method returns null, indicating that the route doesn't + /// use simulations, but initiates the transition by calling either + /// [AnimationController.forward] or [AnimationController.reverse] with + /// [transitionDuration] and the controller's curve. + /// + /// Subclasses can override this method to return a non-null [Simulation]. In + /// this case, the [controller] will instead use the provided simulation to + /// animate the transition using [AnimationController.animateWith] or + /// [AnimationController.animateBackWith], and the [Simulation.x] is forwarded + /// to the value of [animation]. The [controller]'s curve and + /// [transitionDuration] are ignored. + /// + /// This method is invoked each time the navigator pushes or pops this route. + /// The `forward` parameter indicates the direction of the transition: true when + /// the route is pushed, and false when it is popped. + Simulation? createSimulation({ required bool forward }) { + assert(transitionDuration >= Duration.zero, + 'The `duration` must be positive for a non-simulation animation. Received $transitionDuration.'); + return null; + } + Simulation? _createSimulationAndVerify({ required bool forward }) { + final Simulation? simulation = createSimulation(forward: forward); + assert(transitionDuration >= Duration.zero, + "The `duration` must be positive for an animation that doesn't use simulation. " + 'Either set `transitionDuration` or set `createSimulation`. ' + 'Received $transitionDuration.'); + return simulation; + } + T? _result; void _handleStatusChanged(AnimationStatus status) { @@ -275,7 +324,7 @@ abstract class TransitionRoute extends OverlayRoute implements PredictiveB @override void install() { - assert(!_transitionCompleter.isCompleted, 'Cannot install a $runtimeType after disposing it.'); + assert(!debugTransitionCompleted(), 'Cannot install a $runtimeType after disposing it.'); _controller = createAnimationController(); assert(_controller != null, '$runtimeType.createAnimationController() returned null.'); _animation = createAnimation() @@ -290,15 +339,20 @@ abstract class TransitionRoute extends OverlayRoute implements PredictiveB @override TickerFuture didPush() { assert(_controller != null, '$runtimeType.didPush called before calling install() or after calling dispose().'); - assert(!_transitionCompleter.isCompleted, 'Cannot reuse a $runtimeType after disposing it.'); + assert(!debugTransitionCompleted(), 'Cannot reuse a $runtimeType after disposing it.'); super.didPush(); - return _controller!.forward(); + _simulation = _createSimulationAndVerify(forward: true); + if (_simulation == null) { + return _controller!.forward(); + } else { + return _controller!.animateWith(_simulation!); + } } @override void didAdd() { assert(_controller != null, '$runtimeType.didPush called before calling install() or after calling dispose().'); - assert(!_transitionCompleter.isCompleted, 'Cannot reuse a $runtimeType after disposing it.'); + assert(!debugTransitionCompleted(), 'Cannot reuse a $runtimeType after disposing it.'); super.didAdd(); _controller!.value = _controller!.upperBound; } @@ -306,7 +360,7 @@ abstract class TransitionRoute extends OverlayRoute implements PredictiveB @override void didReplace(Route? oldRoute) { assert(_controller != null, '$runtimeType.didReplace called before calling install() or after calling dispose().'); - assert(!_transitionCompleter.isCompleted, 'Cannot reuse a $runtimeType after disposing it.'); + assert(!debugTransitionCompleted(), 'Cannot reuse a $runtimeType after disposing it.'); if (oldRoute is TransitionRoute) { _controller!.value = oldRoute._controller!.value; } @@ -318,14 +372,19 @@ abstract class TransitionRoute extends OverlayRoute implements PredictiveB assert(_controller != null, '$runtimeType.didPop called before calling install() or after calling dispose().'); assert(!_transitionCompleter.isCompleted, 'Cannot reuse a $runtimeType after disposing it.'); _result = result; - _controller!.reverse(); + _simulation = _createSimulationAndVerify(forward: false); + if (_simulation == null) { + _controller!.reverse(); + } else { + _controller!.animateBackWith(_simulation!); + } return super.didPop(result); } @override void didPopNext(Route nextRoute) { assert(_controller != null, '$runtimeType.didPopNext called before calling install() or after calling dispose().'); - assert(!_transitionCompleter.isCompleted, 'Cannot reuse a $runtimeType after disposing it.'); + assert(!debugTransitionCompleted(), 'Cannot reuse a $runtimeType after disposing it.'); _updateSecondaryAnimation(nextRoute); super.didPopNext(nextRoute); } @@ -333,7 +392,7 @@ abstract class TransitionRoute extends OverlayRoute implements PredictiveB @override void didChangeNext(Route? nextRoute) { assert(_controller != null, '$runtimeType.didChangeNext called before calling install() or after calling dispose().'); - assert(!_transitionCompleter.isCompleted, 'Cannot reuse a $runtimeType after disposing it.'); + assert(!debugTransitionCompleted(), 'Cannot reuse a $runtimeType after disposing it.'); _updateSecondaryAnimation(nextRoute); super.didChangeNext(nextRoute); } @@ -562,6 +621,7 @@ abstract class TransitionRoute extends OverlayRoute implements PredictiveB @override void dispose() { assert(!_transitionCompleter.isCompleted, 'Cannot dispose a $runtimeType twice.'); + assert(!debugTransitionCompleted(), 'Cannot dispose a $runtimeType twice.'); _animation?.removeStatusListener(_handleStatusChanged); _performanceModeRequestHandle?.dispose(); _performanceModeRequestHandle = null; diff --git a/packages/flutter/test/cupertino/action_sheet_test.dart b/packages/flutter/test/cupertino/action_sheet_test.dart index 0e0186cfc9..197bae043c 100644 --- a/packages/flutter/test/cupertino/action_sheet_test.dart +++ b/packages/flutter/test/cupertino/action_sheet_test.dart @@ -1717,7 +1717,7 @@ void main() { // Exit animation await tester.tapAt(const Offset(20.0, 20.0)); - await tester.pumpFrames(exitRecorder.record(target), const Duration(milliseconds: 400)); + await tester.pumpFrames(exitRecorder.record(target), const Duration(milliseconds: 450)); // Action sheet has disappeared expect(find.byType(CupertinoActionSheet), findsNothing); @@ -1762,7 +1762,7 @@ void main() { // Exit animation await tester.tapAt(const Offset(20.0, 20.0)); - await tester.pumpFrames(recorder.record(target), const Duration(milliseconds: 400)); + await tester.pumpFrames(recorder.record(target), const Duration(milliseconds: 450)); // Action sheet has disappeared expect(find.byType(CupertinoActionSheet), findsNothing); diff --git a/packages/flutter/test/cupertino/dialog_test.dart b/packages/flutter/test/cupertino/dialog_test.dart index fb0100bf17..e38b1d0f38 100644 --- a/packages/flutter/test/cupertino/dialog_test.dart +++ b/packages/flutter/test/cupertino/dialog_test.dart @@ -1529,27 +1529,27 @@ void main() { await tester.pump(const Duration(milliseconds: 50)); transform = tester.widget(find.byType(Transform)); - expect(transform.transform[0], moreOrLessEquals(1.145, epsilon: 0.001)); + expect(transform.transform[0], moreOrLessEquals(1.205, epsilon: 0.001)); await tester.pump(const Duration(milliseconds: 50)); transform = tester.widget(find.byType(Transform)); - expect(transform.transform[0], moreOrLessEquals(1.044, epsilon: 0.001)); + expect(transform.transform[0], moreOrLessEquals(1.100, epsilon: 0.001)); await tester.pump(const Duration(milliseconds: 50)); transform = tester.widget(find.byType(Transform)); - expect(transform.transform[0], moreOrLessEquals(1.013, epsilon: 0.001)); + expect(transform.transform[0], moreOrLessEquals(1.043, epsilon: 0.001)); await tester.pump(const Duration(milliseconds: 50)); transform = tester.widget(find.byType(Transform)); - expect(transform.transform[0], moreOrLessEquals(1.003, epsilon: 0.001)); + expect(transform.transform[0], moreOrLessEquals(1.017, epsilon: 0.001)); await tester.pump(const Duration(milliseconds: 50)); transform = tester.widget(find.byType(Transform)); - expect(transform.transform[0], moreOrLessEquals(1.000, epsilon: 0.001)); + expect(transform.transform[0], moreOrLessEquals(1.006, epsilon: 0.001)); await tester.pump(const Duration(milliseconds: 50)); transform = tester.widget(find.byType(Transform)); - expect(transform.transform[0], moreOrLessEquals(1.000, epsilon: 0.001)); + expect(transform.transform[0], moreOrLessEquals(1.002, epsilon: 0.001)); await tester.tap(find.text('Delete')); @@ -1607,50 +1607,58 @@ void main() { await tester.pump(const Duration(milliseconds: 50)); transition = tester.firstWidget(fadeTransitionFinder); - expect(transition.opacity.value, moreOrLessEquals(0.081, epsilon: 0.001)); + expect(transition.opacity.value, moreOrLessEquals(0.316, epsilon: 0.001)); await tester.pump(const Duration(milliseconds: 50)); transition = tester.firstWidget(fadeTransitionFinder); - expect(transition.opacity.value, moreOrLessEquals(0.332, epsilon: 0.001)); + expect(transition.opacity.value, moreOrLessEquals(0.665, epsilon: 0.001)); await tester.pump(const Duration(milliseconds: 50)); transition = tester.firstWidget(fadeTransitionFinder); - expect(transition.opacity.value, moreOrLessEquals(0.667, epsilon: 0.001)); + expect(transition.opacity.value, moreOrLessEquals(0.856, epsilon: 0.001)); await tester.pump(const Duration(milliseconds: 50)); transition = tester.firstWidget(fadeTransitionFinder); - expect(transition.opacity.value, moreOrLessEquals(0.918, epsilon: 0.001)); + expect(transition.opacity.value, moreOrLessEquals(0.942, epsilon: 0.001)); await tester.pump(const Duration(milliseconds: 50)); transition = tester.firstWidget(fadeTransitionFinder); - expect(transition.opacity.value, moreOrLessEquals(1.0, epsilon: 0.001)); + expect(transition.opacity.value, moreOrLessEquals(0.977, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(fadeTransitionFinder); + expect(transition.opacity.value, moreOrLessEquals(0.991, epsilon: 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transition = tester.firstWidget(fadeTransitionFinder); + expect(transition.opacity.value, moreOrLessEquals(0.997, epsilon: 0.001)); await tester.tap(find.text('Delete')); // Exit animation, look at reverse FadeTransition. await tester.pump(const Duration(milliseconds: 50)); transition = tester.firstWidget(fadeTransitionFinder); - expect(transition.opacity.value, moreOrLessEquals(1.0, epsilon: 0.001)); + expect(transition.opacity.value, moreOrLessEquals(0.997, epsilon: 0.001)); await tester.pump(const Duration(milliseconds: 50)); transition = tester.firstWidget(fadeTransitionFinder); - expect(transition.opacity.value, moreOrLessEquals(0.918, epsilon: 0.001)); + expect(transition.opacity.value, moreOrLessEquals(0.681, epsilon: 0.001)); await tester.pump(const Duration(milliseconds: 50)); transition = tester.firstWidget(fadeTransitionFinder); - expect(transition.opacity.value, moreOrLessEquals(0.667, epsilon: 0.001)); + expect(transition.opacity.value, moreOrLessEquals(0.333, epsilon: 0.001)); await tester.pump(const Duration(milliseconds: 50)); transition = tester.firstWidget(fadeTransitionFinder); - expect(transition.opacity.value, moreOrLessEquals(0.332, epsilon: 0.001)); + expect(transition.opacity.value, moreOrLessEquals(0.143, epsilon: 0.001)); await tester.pump(const Duration(milliseconds: 50)); transition = tester.firstWidget(fadeTransitionFinder); - expect(transition.opacity.value, moreOrLessEquals(0.081, epsilon: 0.001)); + expect(transition.opacity.value, moreOrLessEquals(0.057, epsilon: 0.001)); await tester.pump(const Duration(milliseconds: 50)); transition = tester.firstWidget(fadeTransitionFinder); - expect(transition.opacity.value, moreOrLessEquals(0.0, epsilon: 0.001)); + expect(transition.opacity.value, moreOrLessEquals(0.022, epsilon: 0.001)); }); testWidgets('Actions are accessible by key', (WidgetTester tester) async { @@ -1882,7 +1890,7 @@ void main() { await tester.pumpAndSettle(); // Should take the right side of the screen - expect(tester.getTopLeft(find.byType(Placeholder)), const Offset(410.0, 0.0)); + expect(tester.getTopLeft(find.byType(Placeholder)), const Offset(410.0, 0.00)); expect(tester.getBottomRight(find.byType(Placeholder)), const Offset(800.0, 600.0)); }); diff --git a/packages/flutter/test/physics/spring_simulation_test.dart b/packages/flutter/test/physics/spring_simulation_test.dart new file mode 100644 index 0000000000..57fb75fdfd --- /dev/null +++ b/packages/flutter/test/physics/spring_simulation_test.dart @@ -0,0 +1,35 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/physics.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('When snapToEnd is set, value is exactly `end` after completion', () { + final SpringDescription description = SpringDescription.withDampingRatio(mass: 1.0, stiffness: 400); + const double time = 0.4; + + final SpringSimulation regularSimulation = SpringSimulation( + description, + 0, + 1, + 0, + tolerance: const Tolerance(distance: 0.1, velocity: 0.1), + ); + expect(regularSimulation.x(time), lessThan(1)); + expect(regularSimulation.dx(time), greaterThan(0)); + + final SpringSimulation snappingSimulation = SpringSimulation( + description, + 0, + 1, + 0, + snapToEnd: true, + tolerance: const Tolerance(distance: 0.1, velocity: 0.1), + ); + // Must be exactly equal + expect(snappingSimulation.x(time), 1); + expect(snappingSimulation.dx(time), 0); + }); +} diff --git a/packages/flutter/test/widgets/routes_test.dart b/packages/flutter/test/widgets/routes_test.dart index ac834bb53b..dbd83571c5 100644 --- a/packages/flutter/test/widgets/routes_test.dart +++ b/packages/flutter/test/widgets/routes_test.dart @@ -7,6 +7,7 @@ import 'dart:ui'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -1473,6 +1474,160 @@ void main() { await tester.pump(const Duration(milliseconds: 1)); expect(find.byKey(containerKey), findsNothing); }); + + testWidgets('Routes can use simulation and ignore durations', (WidgetTester tester) async { + final GlobalKey containerKey = GlobalKey(); + + await tester.pumpWidget(MaterialApp( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + Navigator.of(context).push( + _SimulationRoute( + simulationBuilder: ({required double current, required bool forward}) { + // This simulation takes 1.0 second to transit. + return GravitySimulation( + 0, // Acceleration + 0.0, // Start position + 1.0, // End distance + 1.0); // Init velocity + }, + // Set an extremely long duration so that the route must ignore these + // durations to proceed. + transitionDuration: const Duration(days: 1), + reverseTransitionDuration: const Duration(days: 1), + pageBuilder: (_, Animation animation, Animation secondaryAnimation) { + return Container( + key: containerKey, + color: Colors.green, + ); + }, + transitionBuilder: (BuildContext context, Animation animation, Widget child) { + return child; + } + ), + ); + }, + child: const Text('Open page'), + ); + }, + ); + }, + )); + + // Open the new route. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.text('Open page'), findsNothing); + expect(find.byKey(containerKey), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byKey(containerKey), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byKey(containerKey), findsOneWidget); + + await tester.pumpAndSettle(); + + // Pop the new route. + tester.state(find.byType(Navigator)).pop(); + await tester.pump(); + expect(find.byKey(containerKey), findsOneWidget); + + // Container should be present halfway through the transition. + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byKey(containerKey), findsOneWidget); + + // Container should be present at the very end of the transition. + await tester.pump(const Duration(milliseconds: 490)); + expect(find.byKey(containerKey), findsOneWidget); + + // Container have transitioned out after 500ms. + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byKey(containerKey), findsNothing); + }); + + testWidgets('Routes can use simulation value', (WidgetTester tester) async { + final GlobalKey containerKey = GlobalKey(); + + await tester.pumpWidget(MaterialApp( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + Navigator.of(context).push( + _SimulationRoute( + simulationBuilder: ({required double current, required bool forward}) { + return _ConstantVelocitySimulation(forward: forward, speed: 1.0); // Init velocity + }, + transitionDuration: const Duration(days: 1), + reverseTransitionDuration: const Duration(days: 1), + pageBuilder: (_, Animation animation, Animation secondaryAnimation) { + return Container( + key: containerKey, + color: Colors.green, + ); + }, + transitionBuilder: (BuildContext context, Animation animation, Widget child) { + return FractionalTranslation( + translation: Tween(begin: const Offset(0.0, 1.0), end: Offset.zero) + .evaluate(animation), + child: child, // child is the value returned by pageBuilder + ); + } + ), + ); + }, + child: const Text('Open page'), + ); + }, + ); + }, + )); + + // Open the new route. + await tester.tap(find.byType(ElevatedButton)); + // Must pump two frames for the animation to take effect. The first pump + // starts the animation, the 2nd pump makes the wiget appear. + await tester.pump(); + await tester.pump(); + expect(find.byKey(containerKey), findsOneWidget); + expect(tester.getTopLeft(find.byKey(containerKey)), const Offset(0, 600)); + + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byKey(containerKey), findsOneWidget); + expect(tester.getTopLeft(find.byKey(containerKey)), const Offset(0, 300)); + + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byKey(containerKey), findsOneWidget); + expect(tester.getTopLeft(find.byKey(containerKey)), Offset.zero); + + await tester.pumpAndSettle(); + + // Pop the new route. + tester.state(find.byType(Navigator)).pop(); + await tester.pump(); + await tester.pump(); + expect(find.byKey(containerKey), findsOneWidget); + expect(tester.getTopLeft(find.byKey(containerKey)), Offset.zero); + + // Container should be present halfway through the transition. + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byKey(containerKey), findsOneWidget); + expect(tester.getTopLeft(find.byKey(containerKey)), const Offset(0, 300)); + + // Container should be present at the very end of the transition. + await tester.pump(const Duration(milliseconds: 490)); + expect(find.byKey(containerKey), findsOneWidget); + expect(tester.getTopLeft(find.byKey(containerKey)), const Offset(0, 594)); + + // Container have transitioned out after 500ms. + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byKey(containerKey), findsNothing); + }); }); group('ModalRoute', () { @@ -2631,3 +2786,58 @@ class _RestorableDialogTestWidget extends StatelessWidget { ); } } + +typedef _SimulationBuilder = Simulation Function({ required double current, required bool forward }); +typedef _TransitionBuilder = Widget Function(BuildContext context, Animation animation, Widget child); + +// A route that is driven by a simulation. +class _SimulationRoute extends PageRouteBuilder { + _SimulationRoute({ + required this.simulationBuilder, + required this.transitionBuilder, + required super.pageBuilder, + super.transitionDuration = const Duration(milliseconds: 300), + super.reverseTransitionDuration = const Duration(milliseconds: 300), + }); + + final _SimulationBuilder simulationBuilder; + final _TransitionBuilder transitionBuilder; + + @override + Simulation createSimulation({ required bool forward }) { + return simulationBuilder(current: controller!.value, forward: forward); + } + + @override + Widget buildTransitions(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + return transitionBuilder(context, animation, child); + } +} + +// A simulation that progresses at a constant speed. +// +// If `forward` is true, the simulation goes from 0 to 1, otherwise from 1 to 0. +class _ConstantVelocitySimulation extends Simulation { + _ConstantVelocitySimulation({ + required this.forward, + required this.speed, + }) : _start = forward ? 0.0 : 1.0; + + final bool forward; + final double speed; + final double _start; + + @override + double x(double time) { + return _start + time * dx(time); + } + + @override + double dx(double time) => forward ? speed : -speed; + + @override + bool isDone(double time) { + final double nowX = x(time); + return nowX > 1.0 || nowX < 0; + } +}