Make animation and router support simulation, and use Spring for Cupertino (#155575)
This PR makes `TransitionRoute` support driving the animation with `Simulation`. This is needed for Cupertino widgets, since iOS use "spring simulations" for a majority of their animations. This PR also applies the standard spring animation to `CupertinoDialogRoute` (alert dialogs) and `CupertinoModalPopupRoute` (action sheets). (This PR does not yet support customizing the spring parameters or conveniently using the springs for custom routes, which are left for future PRs.) ### Comparison I tried to create a comparison video for action sheets, however the difference is far less noticeable than I expected. (All clips are precisely aligned at the moment the pointer is lifted.) I guess the original curve _is_ good enough. Nevertheless, the spring simulation is the correct one and we should support it. Edit: [The comment](https://github.com/flutter/flutter/pull/155575#issuecomment-2489303282) below also mentioned that supporting spring animation will improve the fidelity when the animation is caused by a user gesture. I assume this requires initial speed, which is not supported by this PR but we can add it in the future. https://github.com/user-attachments/assets/06d2f684-ad1c-4a4d-8663-a561895f45e9 Also, Flutter's response seems to be always a moment slower than SwiftUI, possibly because Flutter requiring two frames to start the animation (one frame to add the transition widget, one frame for the animation to actually progress.) We probably want to solve it in the future. ## Pre-launch Checklist - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
parent
dd437c7a60
commit
c16aa2d232
@ -383,8 +383,8 @@ class AnimationController extends Animation<double>
|
||||
/// * [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<double>
|
||||
|
||||
/// 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<double>
|
||||
/// 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<double>
|
||||
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;
|
||||
|
@ -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<T> extends PopupRoute<T> {
|
||||
@override
|
||||
Duration get transitionDuration => _kModalPopupTransitionDuration;
|
||||
|
||||
CurvedAnimation? _animation;
|
||||
|
||||
late Tween<Offset> _offsetTween;
|
||||
|
||||
/// {@macro flutter.widgets.DisplayFeatureSubScreen.anchorPoint}
|
||||
final Offset? anchorPoint;
|
||||
|
||||
@override
|
||||
Animation<double> 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<Offset>(
|
||||
begin: const Offset(0.0, 1.0),
|
||||
end: Offset.zero,
|
||||
);
|
||||
return _animation!;
|
||||
}
|
||||
|
||||
@override
|
||||
@ -1185,17 +1202,16 @@ class CupertinoModalPopupRoute<T> extends PopupRoute<T> {
|
||||
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<Offset> _offsetTween = Tween<Offset>(
|
||||
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<T?> showCupertinoModalPopup<T>({
|
||||
);
|
||||
}
|
||||
|
||||
// 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<double> _dialogScaleTween = Tween<double>(begin: 1.3, end: 1.0)
|
||||
.chain(CurveTween(curve: Curves.linearToEaseOut));
|
||||
|
||||
Widget _buildCupertinoDialogTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
|
||||
return child;
|
||||
}
|
||||
@ -1439,33 +1449,36 @@ class CupertinoDialogRoute<T> extends RawDialogRoute<T> {
|
||||
CurvedAnimation? _fadeAnimation;
|
||||
|
||||
@override
|
||||
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> 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<double> animation, Animation<double> 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<T> extends RawDialogRoute<T> {
|
||||
_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<double> _dialogScaleTween = Tween<double>(begin: 1.3, end: 1.0);
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -213,6 +213,23 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> 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<T> extends OverlayRoute<T> 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<T> extends OverlayRoute<T> implements PredictiveB
|
||||
/// the transition controlled by the animation controller created by
|
||||
/// [createAnimationController()].
|
||||
Animation<double> 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<T> extends OverlayRoute<T> 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<T> extends OverlayRoute<T> 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<T> extends OverlayRoute<T> implements PredictiveB
|
||||
@override
|
||||
void didReplace(Route<dynamic>? 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<T> extends OverlayRoute<T> 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<dynamic> 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<T> extends OverlayRoute<T> implements PredictiveB
|
||||
@override
|
||||
void didChangeNext(Route<dynamic>? 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<T> extends OverlayRoute<T> 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;
|
||||
|
@ -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);
|
||||
|
@ -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));
|
||||
});
|
||||
|
||||
|
35
packages/flutter/test/physics/spring_simulation_test.dart
Normal file
35
packages/flutter/test/physics/spring_simulation_test.dart
Normal file
@ -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);
|
||||
});
|
||||
}
|
@ -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<dynamic>(
|
||||
builder: (BuildContext context) {
|
||||
return ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push<void>(
|
||||
_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<double> animation, Animation<double> secondaryAnimation) {
|
||||
return Container(
|
||||
key: containerKey,
|
||||
color: Colors.green,
|
||||
);
|
||||
},
|
||||
transitionBuilder: (BuildContext context, Animation<double> 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<NavigatorState>(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<dynamic>(
|
||||
builder: (BuildContext context) {
|
||||
return ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push<void>(
|
||||
_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<double> animation, Animation<double> secondaryAnimation) {
|
||||
return Container(
|
||||
key: containerKey,
|
||||
color: Colors.green,
|
||||
);
|
||||
},
|
||||
transitionBuilder: (BuildContext context, Animation<double> animation, Widget child) {
|
||||
return FractionalTranslation(
|
||||
translation: Tween<Offset>(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<NavigatorState>(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<double> animation, Widget child);
|
||||
|
||||
// A route that is driven by a simulation.
|
||||
class _SimulationRoute extends PageRouteBuilder<void> {
|
||||
_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<double> animation, Animation<double> 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;
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user