Commit 58ddde88 authored by xster's avatar xster Committed by GitHub

Make Cupertino page transitions match native behaviours (#9138)

* Moved stuff around yet

* Fix depedencies

* Add more dartdoc comments to packages

* Remove Cupertino dependency on material

* Removed mountain_view package and added page transition test

* Fix analyze warnings

* Remove commented code

* Partial solution. Still need to stop the animation on the previous page for modal

* Some review notes

* Move the cupertino back gesture controller’s lifecycle management back to its parent

* Reviews

* Add background color

* Directional curves, full screen transition

* Don’t perform the exit animation if the incoming page is a dialog

* It works!

* Test structures

* Add a bunch of more tests and fix the gallery

* One more comment

* Review notes

* final controller

* Use that sweet sweet `is!` keyword

* Play golf, because I’m bitter that there’s no nullable `as` or something in dart

* Remove a space

* Review notes

* Remove the last deprecated test
parent c7f98efb
......@@ -192,7 +192,8 @@ class DialogDemoState extends State<DialogDemo> {
child: new Text('FULLSCREEN'),
onPressed: () {
Navigator.push(context, new MaterialPageRoute<DismissDialogAction>(
builder: (BuildContext context) => new FullScreenDialogDemo()
builder: (BuildContext context) => new FullScreenDialogDemo(),
fullscreenDialog: true,
));
}
),
......
......@@ -8,6 +8,18 @@ import 'package:flutter/widgets.dart';
const double _kMinFlingVelocity = 1.0; // screen width per second.
const Color _kBackgroundColor = const Color(0xFFEFEFF4); // iOS 10 background color.
// Fractional offset from offscreen to the right to fully on screen.
final FractionalOffsetTween _kRightMiddleTween = new FractionalOffsetTween(
begin: FractionalOffset.topRight,
end: FractionalOffset.topLeft,
);
// Fractional offset from fully on screen to 1/3 offscreen to the left.
final FractionalOffsetTween _kMiddleLeftTween = new FractionalOffsetTween(
begin: FractionalOffset.topLeft,
end: const FractionalOffset(-1.0/3.0, 0.0),
);
/// Provides the native iOS page transition animation.
///
/// Takes in a page widget and a route animation from a [TransitionRoute] and produces an
......@@ -18,21 +30,39 @@ const Color _kBackgroundColor = const Color(0xFFEFEFF4); // iOS 10 background co
class CupertinoPageTransition extends AnimatedWidget {
CupertinoPageTransition({
Key key,
@required Animation<double> animation,
// Linear route animation from 0.0 to 1.0 when this screen is being pushed.
@required Animation<double> incomingRouteAnimation,
// Linear route animation from 0.0 to 1.0 when another screen is being pushed on top of this
// one.
@required Animation<double> outgoingRouteAnimation,
@required this.child,
}) : super(
key: key,
listenable: _kTween.animate(new CurvedAnimation(
parent: animation,
curve: new _CupertinoTransitionCurve(null),
),
));
static final FractionalOffsetTween _kTween = new FractionalOffsetTween(
begin: FractionalOffset.topRight,
end: -FractionalOffset.topRight,
);
}) :
incomingPositionAnimation = _kRightMiddleTween.animate(
new CurvedAnimation(
parent: incomingRouteAnimation,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
)
),
outgoingPositionAnimation = _kMiddleLeftTween.animate(
new CurvedAnimation(
parent: outgoingRouteAnimation,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
)
),
super(
key: key,
// Trigger a rebuild whenever any of the 2 animation route happens.
listenable: new Listenable.merge(
<Listenable>[incomingRouteAnimation, outgoingRouteAnimation]
),
);
// When this page is coming in to cover another page.
final Animation<FractionalOffset> incomingPositionAnimation;
// When this page is becoming covered by another page.
final Animation<FractionalOffset> outgoingPositionAnimation;
final Widget child;
@override
......@@ -40,45 +70,50 @@ class CupertinoPageTransition extends AnimatedWidget {
// TODO(ianh): tell the transform to be un-transformed for hit testing
// but not while being controlled by a gesture.
return new SlideTransition(
position: listenable,
child: new PhysicalModel(
shape: BoxShape.rectangle,
color: _kBackgroundColor,
elevation: 16,
child: child,
)
position: outgoingPositionAnimation,
child: new SlideTransition(
position: incomingPositionAnimation,
child: new PhysicalModel(
shape: BoxShape.rectangle,
color: _kBackgroundColor,
elevation: 32,
child: child,
),
),
);
}
}
// Custom curve for iOS page transitions.
class _CupertinoTransitionCurve extends Curve {
_CupertinoTransitionCurve(this.curve);
/// Transitions used for summoning fullscreen dialogs in iOS such as creating a new
/// calendar event etc by bringing in the next screen from the bottom.
class CupertinoFullscreenDialogTransition extends AnimatedWidget {
CupertinoFullscreenDialogTransition({
Key key,
@required Animation<double> animation,
@required this.child,
}) : super(
key: key,
listenable: _kBottomUpTween.animate(
new CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
)
),
);
final Curve curve;
static final FractionalOffsetTween _kBottomUpTween = new FractionalOffsetTween(
begin: FractionalOffset.bottomLeft,
end: FractionalOffset.topLeft,
);
final Widget child;
@override
double transform(double t) {
// The input [t] is the average of the current and next route's animation.
// This means t=0.5 represents when the route is fully onscreen. At
// t > 0.5, it is partially offscreen to the left (which happens when there
// is another route on top). At t < 0.5, the route is to the right.
// We divide the range into two halves, each with a different transition,
// and scale each half to the range [0.0, 1.0] before applying curves so that
// each half goes through the full range of the curve.
if (t > 0.5) {
// Route is to the left of center.
t = (t - 0.5) * 2.0;
if (curve != null)
t = curve.transform(t);
t = t / 3.0;
t = t / 2.0 + 0.5;
} else {
// Route is to the right of center.
if (curve != null)
t = curve.transform(t * 2.0) / 2.0;
}
return t;
Widget build(BuildContext context) {
return new SlideTransition(
position: listenable,
child: child,
);
}
}
......@@ -96,7 +131,7 @@ class CupertinoBackGestureController extends NavigationGestureController {
@override
void dispose() {
controller.removeStatusListener(handleStatusChanged);
controller.removeStatusListener(_handleStatusChanged);
super.dispose();
}
......@@ -130,13 +165,13 @@ class CupertinoBackGestureController extends NavigationGestureController {
// Don't end the gesture until the transition completes.
final AnimationStatus status = controller.status;
handleStatusChanged(status);
controller?.addStatusListener(handleStatusChanged);
_handleStatusChanged(status);
controller?.addStatusListener(_handleStatusChanged);
return (status == AnimationStatus.reverse || status == AnimationStatus.dismissed);
}
void handleStatusChanged(AnimationStatus status) {
void _handleStatusChanged(AnimationStatus status) {
if (status == AnimationStatus.dismissed)
navigator.pop();
}
......
......@@ -58,12 +58,16 @@ class _MountainViewPageTransition extends AnimatedWidget {
/// By default, when a modal route is replaced by another, the previous route
/// remains in memory. To free all the resources when this is not necessary, set
/// [maintainState] to false.
///
/// Specify whether the incoming page is a fullscreen modal dialog. On iOS, those
/// pages animate bottom->up rather than right->left.
class MaterialPageRoute<T> extends PageRoute<T> {
/// Creates a page route for use in a material design app.
MaterialPageRoute({
@required this.builder,
RouteSettings settings: const RouteSettings(),
this.maintainState: true,
this.fullscreenDialog: false,
}) : super(settings: settings) {
assert(builder != null);
assert(opaque);
......@@ -71,6 +75,7 @@ class MaterialPageRoute<T> extends PageRoute<T> {
/// Builds the primary contents of the route.
final WidgetBuilder builder;
final bool fullscreenDialog;
@override
final bool maintainState;
......@@ -86,6 +91,12 @@ class MaterialPageRoute<T> extends PageRoute<T> {
return nextRoute is MaterialPageRoute<dynamic>;
}
@override
bool canTransitionTo(TransitionRoute<dynamic> nextRoute) {
// Don't perform outgoing animation if the next route is a fullscreen dialog.
return nextRoute is MaterialPageRoute && !nextRoute.fullscreenDialog;
}
@override
void dispose() {
_backGestureController?.dispose();
......@@ -109,6 +120,9 @@ class MaterialPageRoute<T> extends PageRoute<T> {
// allow the user to dismiss the route with a swipe.
if (hasScopedWillPopCallback)
return null;
// Fullscreen dialogs aren't dismissable by back swipe.
if (fullscreenDialog)
return null;
if (controller.status != AnimationStatus.completed)
return null;
assert(_backGestureController == null);
......@@ -146,12 +160,18 @@ class MaterialPageRoute<T> extends PageRoute<T> {
@override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> forwardAnimation, Widget child) {
if (Theme.of(context).platform == TargetPlatform.iOS &&
Navigator.of(context).userGestureInProgress) {
return new CupertinoPageTransition(
animation: new AnimationMean(left: animation, right: forwardAnimation),
child: child
);
if (Theme.of(context).platform == TargetPlatform.iOS) {
if (fullscreenDialog)
return new CupertinoFullscreenDialogTransition(
animation: animation,
child: child,
);
else
return new CupertinoPageTransition(
incomingRouteAnimation: animation,
outgoingRouteAnimation: forwardAnimation,
child: child,
);
} else {
return new _MountainViewPageTransition(
routeAnimation: animation,
......
......@@ -15,7 +15,7 @@ void main() {
'/next': (BuildContext context) {
return new Material(child: new Text('Page 2'));
},
}
},
)
);
......@@ -25,11 +25,11 @@ void main() {
await tester.pump();
await tester.pump(const Duration(milliseconds: 1));
final Opacity widget2Opacity =
tester.element(find.text('Page 2')).ancestorWidgetOfExactType(Opacity);
final Point widget2TopLeft = tester.getTopLeft(find.text('Page 2'));
Opacity widget2Opacity = tester.element(find.text('Page 2')).ancestorWidgetOfExactType(Opacity);
Point widget2TopLeft = tester.getTopLeft(find.text('Page 2'));
final Size widget2Size = tester.getSize(find.text('Page 2'));
// Android transition is vertical only.
expect(widget1TopLeft.x == widget2TopLeft.x, true);
// Page 1 is above page 2 mid-transition.
expect(widget1TopLeft.y < widget2TopLeft.y, true);
......@@ -37,6 +37,29 @@ void main() {
expect(widget2TopLeft.y < widget2Size.height / 4.0, true);
// Animation starts with page 2 being near transparent.
expect(widget2Opacity.opacity < 0.01, true);
await tester.pumpAndSettle();
// Page 2 covers page 1.
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), isOnstage);
tester.state<NavigatorState>(find.byType(Navigator)).pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 1));
widget2Opacity = tester.element(find.text('Page 2')).ancestorWidgetOfExactType(Opacity);
widget2TopLeft = tester.getTopLeft(find.text('Page 2'));
// Page 2 starts to move down.
expect(widget1TopLeft.y < widget2TopLeft.y, true);
// Page 2 starts to lose opacity.
expect(widget2Opacity.opacity < 1.0, true);
await tester.pumpAndSettle();
expect(find.text('Page 1'), isOnstage);
expect(find.text('Page 2'), findsNothing);
});
testWidgets('test iOS page transition', (WidgetTester tester) async {
......@@ -48,21 +71,210 @@ void main() {
'/next': (BuildContext context) {
return new Material(child: new Text('Page 2'));
},
}
},
)
);
final Point widget1TopLeft = tester.getTopLeft(find.text('Page 1'));
final Point widget1InitialTopLeft = tester.getTopLeft(find.text('Page 1'));
tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next');
await tester.pump();
await tester.pump(const Duration(milliseconds: 250));
await tester.pump(const Duration(milliseconds: 100));
final Point widget2TopLeft = tester.getTopLeft(find.text('Page 2'));
Point widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1'));
Point widget2TopLeft = tester.getTopLeft(find.text('Page 2'));
// This is currently an incorrect behaviour and we want right to left transition instead.
// See https://github.com/flutter/flutter/issues/8726.
expect(widget1TopLeft.x == widget2TopLeft.x, true);
expect(widget1TopLeft.y - widget2TopLeft.y < 0, true);
// Page 1 is moving to the left.
expect(widget1TransientTopLeft.x < widget1InitialTopLeft.x, true);
// Page 1 isn't moving vertically.
expect(widget1TransientTopLeft.y == widget1InitialTopLeft.y, true);
// iOS transition is horizontal only.
expect(widget1InitialTopLeft.y == widget2TopLeft.y, true);
// Page 2 is coming in from the right.
expect(widget2TopLeft.x > widget1InitialTopLeft.x, true);
await tester.pumpAndSettle();
// Page 2 covers page 1.
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), isOnstage);
tester.state<NavigatorState>(find.byType(Navigator)).pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1'));
widget2TopLeft = tester.getTopLeft(find.text('Page 2'));
// Page 1 is coming back from the left.
expect(widget1TransientTopLeft.x < widget1InitialTopLeft.x, true);
// Page 1 isn't moving vertically.
expect(widget1TransientTopLeft.y == widget1InitialTopLeft.y, true);
// iOS transition is horizontal only.
expect(widget1InitialTopLeft.y == widget2TopLeft.y, true);
// Page 2 is leaving towards the right.
expect(widget2TopLeft.x > widget1InitialTopLeft.x, true);
await tester.pumpAndSettle();
expect(find.text('Page 1'), isOnstage);
expect(find.text('Page 2'), findsNothing);
widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1'));
// Page 1 is back where it started.
expect(widget1InitialTopLeft == widget1TransientTopLeft, true);
});
testWidgets('test iOS fullscreen dialog transition', (WidgetTester tester) async {
await tester.pumpWidget(
new MaterialApp(
theme: new ThemeData(platform: TargetPlatform.iOS),
home: new Material(child: new Text('Page 1')),
)
);
final Point widget1InitialTopLeft = tester.getTopLeft(find.text('Page 1'));
tester.state<NavigatorState>(find.byType(Navigator)).push(new MaterialPageRoute<Null>(
builder: (BuildContext context) {
return new Material(child: new Text('Page 2'));
},
fullscreenDialog: true,
));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
Point widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1'));
Point widget2TopLeft = tester.getTopLeft(find.text('Page 2'));
// Page 1 doesn't move.
expect(widget1TransientTopLeft == widget1InitialTopLeft, true);
// Fullscreen dialogs transitions vertically only.
expect(widget1InitialTopLeft.x == widget2TopLeft.x, true);
// Page 2 is coming in from the bottom.
expect(widget2TopLeft.y > widget1InitialTopLeft.y, true);
await tester.pumpAndSettle();
// Page 2 covers page 1.
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), isOnstage);
tester.state<NavigatorState>(find.byType(Navigator)).pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1'));
widget2TopLeft = tester.getTopLeft(find.text('Page 2'));
// Page 1 doesn't move.
expect(widget1TransientTopLeft == widget1InitialTopLeft, true);
// Fullscreen dialogs transitions vertically only.
expect(widget1InitialTopLeft.x == widget2TopLeft.x, true);
// Page 2 is leaving towards the bottom.
expect(widget2TopLeft.y > widget1InitialTopLeft.y, true);
await tester.pumpAndSettle();
expect(find.text('Page 1'), isOnstage);
expect(find.text('Page 2'), findsNothing);
widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1'));
// Page 1 is back where it started.
expect(widget1InitialTopLeft == widget1TransientTopLeft, true);
});
testWidgets('test no back gesture on Android', (WidgetTester tester) async {
await tester.pumpWidget(
new MaterialApp(
theme: new ThemeData(platform: TargetPlatform.android),
home: new Scaffold(body: new Text('Page 1')),
routes: <String, WidgetBuilder>{
'/next': (BuildContext context) {
return new Scaffold(body: new Text('Page 2'));
},
},
)
);
tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next');
await tester.pumpAndSettle();
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), isOnstage);
// Drag from left edge to invoke the gesture.
final TestGesture gesture = await tester.startGesture(const Point(5.0, 100.0));
await gesture.moveBy(const Offset(400.0, 0.0));
await tester.pumpAndSettle();
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), isOnstage);
// Page 2 didn't move
expect(tester.getTopLeft(find.text('Page 2')), Point.origin);
});
testWidgets('test back gesture on iOS', (WidgetTester tester) async {
await tester.pumpWidget(
new MaterialApp(
theme: new ThemeData(platform: TargetPlatform.iOS),
home: new Scaffold(body: new Text('Page 1')),
routes: <String, WidgetBuilder>{
'/next': (BuildContext context) {
return new Scaffold(body: new Text('Page 2'));
},
},
)
);
tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next');
await tester.pumpAndSettle();
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), isOnstage);
// Drag from left edge to invoke the gesture.
final TestGesture gesture = await tester.startGesture(const Point(5.0, 100.0));
await gesture.moveBy(const Offset(400.0, 0.0));
await tester.pumpAndSettle();
// Page 1 is now visible.
expect(find.text('Page 1'), isOnstage);
expect(find.text('Page 2'), isOnstage);
});
testWidgets('test no back gesture on iOS fullscreen dialogs', (WidgetTester tester) async {
await tester.pumpWidget(
new MaterialApp(
theme: new ThemeData(platform: TargetPlatform.iOS),
home: new Scaffold(body: new Text('Page 1')),
)
);
tester.state<NavigatorState>(find.byType(Navigator)).push(new MaterialPageRoute<Null>(
builder: (BuildContext context) {
return new Scaffold(body: new Text('Page 2'));
},
fullscreenDialog: true,
));
await tester.pumpAndSettle();
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), isOnstage);
// Drag from left edge to invoke the gesture.
final TestGesture gesture = await tester.startGesture(const Point(5.0, 100.0));
await gesture.moveBy(const Offset(400.0, 0.0));
await tester.pumpAndSettle();
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), isOnstage);
// Page 2 didn't move
expect(tester.getTopLeft(find.text('Page 2')), Point.origin);
});
}
......@@ -128,126 +128,6 @@ void main() {
expect(Navigator.canPop(containerKey1.currentContext), isFalse);
});
testWidgets('Check back gesture works on iOS', (WidgetTester tester) async {
final GlobalKey containerKey1 = new GlobalKey();
final GlobalKey containerKey2 = new GlobalKey();
final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
'/': (_) => new Scaffold(key: containerKey1, body: new Text('Home')),
'/settings': (_) => new Scaffold(key: containerKey2, body: new Text('Settings')),
};
await tester.pumpWidget(new MaterialApp(
routes: routes,
theme: new ThemeData(platform: TargetPlatform.iOS),
));
Navigator.pushNamed(containerKey1.currentContext, '/settings');
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.text('Home'), findsNothing);
expect(find.text('Settings'), isOnstage);
// Drag from left edge to invoke the gesture.
final TestGesture gesture = await tester.startGesture(const Point(5.0, 100.0));
await gesture.moveBy(const Offset(50.0, 0.0));
await tester.pump();
// Home is now visible.
expect(find.text('Home'), isOnstage);
expect(find.text('Settings'), isOnstage);
});
testWidgets('Check back gesture does nothing on android', (WidgetTester tester) async {
final GlobalKey containerKey1 = new GlobalKey();
final GlobalKey containerKey2 = new GlobalKey();
final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
'/': (_) => new Scaffold(key: containerKey1, body: new Text('Home')),
'/settings': (_) => new Scaffold(key: containerKey2, body: new Text('Settings')),
};
await tester.pumpWidget(new MaterialApp(
routes: routes,
theme: new ThemeData(platform: TargetPlatform.android),
));
Navigator.pushNamed(containerKey1.currentContext, '/settings');
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.text('Home'), findsNothing);
expect(find.text('Settings'), isOnstage);
// Drag from left edge to invoke the gesture.
final TestGesture gesture = await tester.startGesture(const Point(5.0, 100.0));
await gesture.moveBy(const Offset(50.0, 0.0));
await tester.pump();
expect(find.text('Home'), findsNothing);
expect(find.text('Settings'), isOnstage);
});
testWidgets('Check page transition positioning on iOS', (WidgetTester tester) async {
final GlobalKey containerKey1 = new GlobalKey();
final GlobalKey containerKey2 = new GlobalKey();
final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
'/': (_) => new Scaffold(key: containerKey1, body: new Text('Home')),
'/settings': (_) => new Scaffold(key: containerKey2, body: new Text('Settings')),
};
await tester.pumpWidget(new MaterialApp(
routes: routes,
theme: new ThemeData(platform: TargetPlatform.iOS),
));
Navigator.pushNamed(containerKey1.currentContext, '/settings');
await tester.pump();
await tester.pump(const Duration(milliseconds: 16));
expect(find.text('Home'), isOnstage);
expect(find.text('Settings'), isOnstage);
// Home page is staying in place.
Point homeOffset = tester.getTopLeft(find.text('Home'));
expect(homeOffset.x, 0.0);
expect(homeOffset.y, 0.0);
// Settings page is sliding up from the bottom.
Point settingsOffset = tester.getTopLeft(find.text('Settings'));
expect(settingsOffset.x, 0.0);
expect(settingsOffset.y, greaterThan(0.0));
await tester.pump(const Duration(seconds: 1));
expect(find.text('Home'), findsNothing);
expect(find.text('Settings'), isOnstage);
// Settings page is in position.
settingsOffset = tester.getTopLeft(find.text('Settings'));
expect(settingsOffset.x, 0.0);
expect(settingsOffset.y, 0.0);
Navigator.pop(containerKey1.currentContext);
await tester.pump();
await tester.pump(const Duration(milliseconds: 16));
// Home page is staying in place.
homeOffset = tester.getTopLeft(find.text('Home'));
expect(homeOffset.x, 0.0);
expect(homeOffset.y, 0.0);
// Settings page is sliding down off the bottom.
settingsOffset = tester.getTopLeft(find.text('Settings'));
expect(settingsOffset.x, 0.0);
expect(settingsOffset.y, greaterThan(0.0));
await tester.pump(const Duration(seconds: 1));
});
testWidgets('Check back gesture disables Heroes', (WidgetTester tester) async {
final GlobalKey containerKey1 = new GlobalKey();
final GlobalKey containerKey2 = new GlobalKey();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment