diff --git a/examples/flutter_gallery/lib/demo/material/dialog_demo.dart b/examples/flutter_gallery/lib/demo/material/dialog_demo.dart index 5f80b34ea30..7210568f5bb 100644 --- a/examples/flutter_gallery/lib/demo/material/dialog_demo.dart +++ b/examples/flutter_gallery/lib/demo/material/dialog_demo.dart @@ -192,7 +192,8 @@ class DialogDemoState extends State { child: new Text('FULLSCREEN'), onPressed: () { Navigator.push(context, new MaterialPageRoute( - builder: (BuildContext context) => new FullScreenDialogDemo() + builder: (BuildContext context) => new FullScreenDialogDemo(), + fullscreenDialog: true, )); } ), diff --git a/packages/flutter/lib/src/cupertino/page.dart b/packages/flutter/lib/src/cupertino/page.dart index 8cc5d3ea78f..9c33be96d60 100644 --- a/packages/flutter/lib/src/cupertino/page.dart +++ b/packages/flutter/lib/src/cupertino/page.dart @@ -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 animation, + // Linear route animation from 0.0 to 1.0 when this screen is being pushed. + @required Animation incomingRouteAnimation, + // Linear route animation from 0.0 to 1.0 when another screen is being pushed on top of this + // one. + @required Animation 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( + [incomingRouteAnimation, outgoingRouteAnimation] + ), + ); + // When this page is coming in to cover another page. + final Animation incomingPositionAnimation; + // When this page is becoming covered by another page. + final Animation 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 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(); } diff --git a/packages/flutter/lib/src/material/page.dart b/packages/flutter/lib/src/material/page.dart index dbce1bf652f..89a2e034a38 100644 --- a/packages/flutter/lib/src/material/page.dart +++ b/packages/flutter/lib/src/material/page.dart @@ -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 extends PageRoute { /// 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 extends PageRoute { /// Builds the primary contents of the route. final WidgetBuilder builder; + final bool fullscreenDialog; @override final bool maintainState; @@ -86,6 +91,12 @@ class MaterialPageRoute extends PageRoute { return nextRoute is MaterialPageRoute; } + @override + bool canTransitionTo(TransitionRoute 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 extends PageRoute { // 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 extends PageRoute { @override Widget buildTransitions(BuildContext context, Animation animation, Animation 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, diff --git a/packages/flutter/test/material/page_test.dart b/packages/flutter/test/material/page_test.dart index 66cdf9a05bf..4e9b4ba4a83 100644 --- a/packages/flutter/test/material/page_test.dart +++ b/packages/flutter/test/material/page_test.dart @@ -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(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(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(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(find.byType(Navigator)).push(new MaterialPageRoute( + 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(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: { + '/next': (BuildContext context) { + return new Scaffold(body: new Text('Page 2')); + }, + }, + ) + ); + + tester.state(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: { + '/next': (BuildContext context) { + return new Scaffold(body: new Text('Page 2')); + }, + }, + ) + ); + + tester.state(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(find.byType(Navigator)).push(new MaterialPageRoute( + 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); }); } diff --git a/packages/flutter/test/widgets/page_transitions_test.dart b/packages/flutter/test/widgets/page_transitions_test.dart index 6cb154253c2..13979f59cea 100644 --- a/packages/flutter/test/widgets/page_transitions_test.dart +++ b/packages/flutter/test/widgets/page_transitions_test.dart @@ -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 routes = { - '/': (_) => 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 routes = { - '/': (_) => 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 routes = { - '/': (_) => 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();