Skip to content

Commit c16aa2d

Browse files
authored
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](flutter/flutter#155575 (comment)) 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
1 parent dd437c7 commit c16aa2d

File tree

8 files changed

+462
-81
lines changed

8 files changed

+462
-81
lines changed

packages/flutter/lib/src/animation/animation_controller.dart

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -383,8 +383,8 @@ class AnimationController extends Animation<double>
383383
/// * [stop], which aborts the animation without changing its value or status
384384
/// and without dispatching any notifications other than completing or
385385
/// canceling the [TickerFuture].
386-
/// * [forward], [reverse], [animateTo], [animateWith], [fling], and [repeat],
387-
/// which start the animation controller.
386+
/// * [forward], [reverse], [animateTo], [animateWith], [animateBackWith],
387+
/// [fling], and [repeat], which start the animation controller.
388388
set value(double newValue) {
389389
stop();
390390
_internalSetValue(newValue);
@@ -802,6 +802,7 @@ class AnimationController extends Animation<double>
802802

803803
/// Drives the animation according to the given simulation.
804804
///
805+
/// {@template flutter.animation.AnimationController.animateWith}
805806
/// The values from the simulation are clamped to the [lowerBound] and
806807
/// [upperBound]. To avoid this, consider creating the [AnimationController]
807808
/// using the [AnimationController.unbounded] constructor.
@@ -811,9 +812,15 @@ class AnimationController extends Animation<double>
811812
/// The most recently returned [TickerFuture], if any, is marked as having been
812813
/// canceled, meaning the future never completes and its [TickerFuture.orCancel]
813814
/// derivative future completes with a [TickerCanceled] error.
815+
/// {@endtemplate}
814816
///
815817
/// The [status] is always [AnimationStatus.forward] for the entire duration
816818
/// of the simulation.
819+
///
820+
/// See also:
821+
///
822+
/// * [animateBackWith], which is like this method but the status is always
823+
/// [AnimationStatus.reverse].
817824
TickerFuture animateWith(Simulation simulation) {
818825
assert(
819826
_ticker != null,
@@ -825,6 +832,29 @@ class AnimationController extends Animation<double>
825832
return _startSimulation(simulation);
826833
}
827834

835+
/// Drives the animation according to the given simulation with a [status] of
836+
/// [AnimationStatus.reverse].
837+
///
838+
/// {@macro flutter.animation.AnimationController.animateWith}
839+
///
840+
/// The [status] is always [AnimationStatus.reverse] for the entire duration
841+
/// of the simulation.
842+
///
843+
/// See also:
844+
///
845+
/// * [animateWith], which is like this method but the status is always
846+
/// [AnimationStatus.forward].
847+
TickerFuture animateBackWith(Simulation simulation) {
848+
assert(
849+
_ticker != null,
850+
'AnimationController.animateWith() called after AnimationController.dispose()\n'
851+
'AnimationController methods should not be used after calling dispose.',
852+
);
853+
stop();
854+
_direction = _AnimationDirection.reverse;
855+
return _startSimulation(simulation);
856+
}
857+
828858
TickerFuture _startSimulation(Simulation simulation) {
829859
assert(!isAnimating);
830860
_simulation = simulation;

packages/flutter/lib/src/cupertino/route.dart

Lines changed: 63 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import 'dart:ui' show ImageFilter;
2020

2121
import 'package:flutter/foundation.dart';
2222
import 'package:flutter/gestures.dart';
23+
import 'package:flutter/physics.dart';
2324
import 'package:flutter/rendering.dart';
2425
import 'package:flutter/widgets.dart';
2526

@@ -1065,6 +1066,30 @@ class _CupertinoEdgeShadowPainter extends BoxPainter {
10651066
}
10661067
}
10671068

1069+
// The stiffness used by dialogs and action sheets.
1070+
//
1071+
// The stiffness value is obtained by examining the properties of
1072+
// `CASpringAnimation` in Xcode. The damping value is derived similarly, with
1073+
// additional precision calculated based on `_kStandardStiffness` to ensure a
1074+
// damping ratio of 1 (critically damped): damping = 2 * sqrt(stiffness)
1075+
const double _kStandardStiffness = 522.35;
1076+
const double _kStandardDamping = 45.7099552;
1077+
const SpringDescription _kStandardSpring = SpringDescription(
1078+
mass: 1,
1079+
stiffness: _kStandardStiffness,
1080+
damping: _kStandardDamping,
1081+
);
1082+
// The iOS spring animation duration is 0.404 seconds, based on the properties
1083+
// of `CASpringAnimation` in Xcode. At this point, the spring's position
1084+
// `x(0.404)` is approximately 0.9990000, suggesting that iOS uses a position
1085+
// tolerance of 1e-3 (matching the default `_epsilonDefault` value).
1086+
//
1087+
// However, the spring's velocity `dx(0.404)` is about 0.02, indicating that iOS
1088+
// may not consider velocity when determining the animation's end condition. To
1089+
// account for this, a larger velocity tolerance is applied here for added
1090+
// safety.
1091+
const Tolerance _kStandardTolerance = Tolerance(velocity: 0.03);
1092+
10681093
/// A route that shows a modal iOS-style popup that slides up from the
10691094
/// bottom of the screen.
10701095
///
@@ -1144,29 +1169,21 @@ class CupertinoModalPopupRoute<T> extends PopupRoute<T> {
11441169
@override
11451170
Duration get transitionDuration => _kModalPopupTransitionDuration;
11461171

1147-
CurvedAnimation? _animation;
1148-
1149-
late Tween<Offset> _offsetTween;
1150-
11511172
/// {@macro flutter.widgets.DisplayFeatureSubScreen.anchorPoint}
11521173
final Offset? anchorPoint;
11531174

11541175
@override
1155-
Animation<double> createAnimation() {
1156-
assert(_animation == null);
1157-
_animation = CurvedAnimation(
1158-
parent: super.createAnimation(),
1159-
1160-
// These curves were initially measured from native iOS horizontal page
1161-
// route animations and seemed to be a good match here as well.
1162-
curve: Curves.linearToEaseOut,
1163-
reverseCurve: Curves.linearToEaseOut.flipped,
1164-
);
1165-
_offsetTween = Tween<Offset>(
1166-
begin: const Offset(0.0, 1.0),
1167-
end: Offset.zero,
1176+
Simulation createSimulation({ required bool forward }) {
1177+
assert(!debugTransitionCompleted(), 'Cannot reuse a $runtimeType after disposing it.');
1178+
final double end = forward ? 1.0 : 0.0;
1179+
return SpringSimulation(
1180+
_kStandardSpring,
1181+
controller!.value,
1182+
end,
1183+
0,
1184+
tolerance: _kStandardTolerance,
1185+
snapToEnd: true,
11681186
);
1169-
return _animation!;
11701187
}
11711188

11721189
@override
@@ -1185,17 +1202,16 @@ class CupertinoModalPopupRoute<T> extends PopupRoute<T> {
11851202
return Align(
11861203
alignment: Alignment.bottomCenter,
11871204
child: FractionalTranslation(
1188-
translation: _offsetTween.evaluate(_animation!),
1205+
translation: _offsetTween.evaluate(animation),
11891206
child: child,
11901207
),
11911208
);
11921209
}
11931210

1194-
@override
1195-
void dispose() {
1196-
_animation?.dispose();
1197-
super.dispose();
1198-
}
1211+
static final Tween<Offset> _offsetTween = Tween<Offset>(
1212+
begin: const Offset(0.0, 1.0),
1213+
end: Offset.zero,
1214+
);
11991215
}
12001216

12011217
/// Shows a modal iOS-style popup that slides up from the bottom of the screen.
@@ -1286,12 +1302,6 @@ Future<T?> showCupertinoModalPopup<T>({
12861302
);
12871303
}
12881304

1289-
// The curve and initial scale values were mostly eyeballed from iOS, however
1290-
// they reuse the same animation curve that was modeled after native page
1291-
// transitions.
1292-
final Animatable<double> _dialogScaleTween = Tween<double>(begin: 1.3, end: 1.0)
1293-
.chain(CurveTween(curve: Curves.linearToEaseOut));
1294-
12951305
Widget _buildCupertinoDialogTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
12961306
return child;
12971307
}
@@ -1439,33 +1449,36 @@ class CupertinoDialogRoute<T> extends RawDialogRoute<T> {
14391449
CurvedAnimation? _fadeAnimation;
14401450

14411451
@override
1442-
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
1452+
Simulation createSimulation({ required bool forward }) {
1453+
assert(!debugTransitionCompleted(), 'Cannot reuse a $runtimeType after disposing it.');
1454+
final double end = forward ? 1.0 : 0.0;
1455+
return SpringSimulation(
1456+
_kStandardSpring,
1457+
controller!.value,
1458+
end,
1459+
0,
1460+
tolerance: _kStandardTolerance,
1461+
snapToEnd: true,
1462+
);
1463+
}
14431464

1465+
@override
1466+
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
14441467
if (transitionBuilder != null) {
14451468
return super.buildTransitions(context, animation, secondaryAnimation, child);
14461469
}
14471470

1448-
if (_fadeAnimation?.parent != animation) {
1449-
_fadeAnimation?.dispose();
1450-
_fadeAnimation = CurvedAnimation(
1451-
parent: animation,
1452-
curve: Curves.easeInOut,
1453-
);
1454-
}
1455-
1456-
final CurvedAnimation fadeAnimation = _fadeAnimation!;
1457-
14581471
if (animation.status == AnimationStatus.reverse) {
14591472
return FadeTransition(
1460-
opacity: fadeAnimation,
1461-
child: super.buildTransitions(context, animation, secondaryAnimation, child),
1473+
opacity: animation,
1474+
child: child,
14621475
);
14631476
}
14641477
return FadeTransition(
1465-
opacity: fadeAnimation,
1478+
opacity: animation,
14661479
child: ScaleTransition(
14671480
scale: animation.drive(_dialogScaleTween),
1468-
child: super.buildTransitions(context, animation, secondaryAnimation, child),
1481+
child: child,
14691482
),
14701483
);
14711484
}
@@ -1475,4 +1488,9 @@ class CupertinoDialogRoute<T> extends RawDialogRoute<T> {
14751488
_fadeAnimation?.dispose();
14761489
super.dispose();
14771490
}
1491+
1492+
// The curve and initial scale values were mostly eyeballed from iOS, however
1493+
// they reuse the same animation curve that was modeled after native page
1494+
// transitions.
1495+
static final Tween<double> _dialogScaleTween = Tween<double>(begin: 1.3, end: 1.0);
14781496
}

packages/flutter/lib/src/physics/spring_simulation.dart

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,17 +139,25 @@ class SpringSimulation extends Simulation {
139139
/// The units for the velocity are L/T, where L is the aforementioned
140140
/// arbitrary unit of length, and T is the time unit used for driving the
141141
/// [SpringSimulation].
142+
///
143+
/// If `snapToEnd` is true, [x] will be set to `end` and [dx] to 0 when
144+
/// [isDone] returns true. This is useful for transitions that require the
145+
/// simulation to stop exactly at the end value, since the spring may not
146+
/// naturally reach the target precisely. Defaults to false.
142147
SpringSimulation(
143148
SpringDescription spring,
144149
double start,
145150
double end,
146151
double velocity, {
152+
bool snapToEnd = false,
147153
super.tolerance,
148154
}) : _endPosition = end,
149-
_solution = _SpringSolution(spring, start - end, velocity);
155+
_solution = _SpringSolution(spring, start - end, velocity),
156+
_snapToEnd = snapToEnd;
150157

151158
final double _endPosition;
152159
final _SpringSolution _solution;
160+
final bool _snapToEnd;
153161

154162
/// The kind of spring being simulated, for debugging purposes.
155163
///
@@ -158,10 +166,22 @@ class SpringSimulation extends Simulation {
158166
SpringType get type => _solution.type;
159167

160168
@override
161-
double x(double time) => _endPosition + _solution.x(time);
169+
double x(double time) {
170+
if (_snapToEnd && isDone(time)) {
171+
return _endPosition;
172+
} else {
173+
return _endPosition + _solution.x(time);
174+
}
175+
}
162176

163177
@override
164-
double dx(double time) => _solution.dx(time);
178+
double dx(double time) {
179+
if (_snapToEnd && isDone(time)) {
180+
return 0;
181+
} else {
182+
return _solution.dx(time);
183+
}
184+
}
165185

166186
@override
167187
bool isDone(double time) {

0 commit comments

Comments
 (0)