diff --git a/packages/flutter/lib/src/cupertino/route.dart b/packages/flutter/lib/src/cupertino/route.dart index 431fcea2e5e..330e91559e1 100644 --- a/packages/flutter/lib/src/cupertino/route.dart +++ b/packages/flutter/lib/src/cupertino/route.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; @@ -9,6 +11,9 @@ import 'package:flutter/widgets.dart'; const double _kBackGestureWidth = 20.0; const double _kMinFlingVelocity = 1.0; // Screen widths per second. +// Barrier color for a Cupertino modal barrier. +const Color _kModalBarrierColor = Color(0x6604040F); + // Offset from offscreen to the right to fully on screen. final Tween _kRightMiddleTween = new Tween( begin: const Offset(1.0, 0.0), @@ -709,3 +714,77 @@ class _CupertinoEdgeShadowPainter extends BoxPainter { canvas.drawRect(rect, paint); } } + +Widget _buildCupertinoDialogTransitions(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + final CurvedAnimation fadeAnimation = new CurvedAnimation( + parent: animation, + curve: Curves.easeInOut, + ); + if (animation.status == AnimationStatus.reverse) { + return new FadeTransition( + opacity: fadeAnimation, + child: child, + ); + } + return new FadeTransition( + opacity: fadeAnimation, + child: ScaleTransition( + child: child, + scale: new Tween( + begin: 1.2, + end: 1.0, + ).animate( + new CurvedAnimation( + parent: animation, + curve: Curves.fastOutSlowIn, + ), + ), + ), + ); +} + +/// Displays an iOS-style dialog above the current contents of the app, with +/// iOS-style entrance and exit animations, modal barrier color, and modal +/// barrier behavior (the dialog is not dismissible with a tap on the barrier). +/// +/// This function takes a `builder` which typically builds a [CupertinoDialog] +/// or [CupertinoAlertDialog] widget. Content below the dialog is dimmed with a +/// [ModalBarrier]. The widget returned by the `builder` does not share a +/// context with the location that `showCupertinoDialog` is originally called +/// from. Use a [StatefulBuilder] or a custom [StatefulWidget] if the dialog +/// needs to update dynamically. +/// +/// The `context` argument is used to look up the [Navigator] for the dialog. +/// It is only used when the method is called. Its corresponding widget can +/// be safely removed from the tree before the dialog is closed. +/// +/// Returns a [Future] that resolves to the value (if any) that was passed to +/// [Navigator.pop] when the dialog was closed. +/// +/// The dialog route created by this method is pushed to the root navigator. +/// If the application has multiple [Navigator] objects, it may be necessary to +/// call `Navigator.of(context, rootNavigator: true).pop(result)` to close the +/// dialog rather than just `Navigator.pop(context, result)`. +/// +/// See also: +/// * [CupertinoDialog], an iOS-style dialog. +/// * [CupertinoAlertDialog], an iOS-style alert dialog. +/// * [showDialog], which displays a Material-style dialog. +/// * [showGeneralDialog], which allows for customization of the dialog popup. +/// * +Future showCupertinoDialog({ + @required BuildContext context, + @required WidgetBuilder builder, +}) { + assert(builder != null); + return showGeneralDialog( + context: context, + barrierDismissible: false, + barrierColor: _kModalBarrierColor, + transitionDuration: const Duration(milliseconds: 300), + pageBuilder: (BuildContext context, Animation animation, Animation secondaryAnimation) { + return builder(context); + }, + transitionBuilder: _buildCupertinoDialogTransitions, + ); +} \ No newline at end of file diff --git a/packages/flutter/lib/src/material/dialog.dart b/packages/flutter/lib/src/material/dialog.dart index 0a60297d1b9..9c939602ed8 100644 --- a/packages/flutter/lib/src/material/dialog.dart +++ b/packages/flutter/lib/src/material/dialog.dart @@ -543,70 +543,25 @@ class SimpleDialog extends StatelessWidget { } } -class _DialogRoute extends PopupRoute { - _DialogRoute({ - @required this.theme, - bool barrierDismissible = true, - this.barrierLabel, - @required this.child, - RouteSettings settings, - }) : assert(barrierDismissible != null), - _barrierDismissible = barrierDismissible, - super(settings: settings); - - final Widget child; - final ThemeData theme; - - @override - Duration get transitionDuration => const Duration(milliseconds: 150); - - @override - bool get barrierDismissible => _barrierDismissible; - final bool _barrierDismissible; - - @override - Color get barrierColor => Colors.black54; - - @override - final String barrierLabel; - - @override - Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { - return new SafeArea( - child: new Builder( - builder: (BuildContext context) { - final Widget annotatedChild = new Semantics( - child: child, - scopesRoute: true, - explicitChildNodes: true, - ); - return theme != null - ? new Theme(data: theme, child: annotatedChild) - : annotatedChild; - } - ), - ); - } - - @override - Widget buildTransitions(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { - return new FadeTransition( - opacity: new CurvedAnimation( - parent: animation, - curve: Curves.easeOut - ), - child: child - ); - } +Widget _buildMaterialDialogTransitions(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + return new FadeTransition( + opacity: new CurvedAnimation( + parent: animation, + curve: Curves.easeOut, + ), + child: child, + ); } -/// Displays a dialog above the current contents of the app. +/// Displays a Material dialog above the current contents of the app, with +/// Material entrance and exit animations, modal barrier color, and modal +/// barrier behavior (dialog is dismissible with a tap on the barrier). /// /// This function takes a `builder` which typically builds a [Dialog] widget. -/// Content below the dialog is dimmed with a [ModalBarrier]. This widget does -/// not share a context with the location that `showDialog` is originally -/// called from. Use a [StatefulBuilder] or a custom [StatefulWidget] if the -/// dialog needs to update dynamically. +/// Content below the dialog is dimmed with a [ModalBarrier]. The widget +/// returned by the `builder` does not share a context with the location that +/// `showDialog` is originally called from. Use a [StatefulBuilder] or a +/// custom [StatefulWidget] if the dialog needs to update dynamically. /// /// The `context` argument is used to look up the [Navigator] and [Theme] for /// the dialog. It is only used when the method is called. Its corresponding @@ -620,13 +575,15 @@ class _DialogRoute extends PopupRoute { /// The dialog route created by this method is pushed to the root navigator. /// If the application has multiple [Navigator] objects, it may be necessary to /// call `Navigator.of(context, rootNavigator: true).pop(result)` to close the -/// dialog rather just 'Navigator.pop(context, result)`. +/// dialog rather than just `Navigator.pop(context, result)`. /// /// See also: /// * [AlertDialog], for dialogs that have a row of buttons below a body. /// * [SimpleDialog], which handles the scrolling of the contents and does /// not show buttons below its body. /// * [Dialog], on which [SimpleDialog] and [AlertDialog] are based. +/// * [showCupertinoDialog], which displays an iOS-style dialog. +/// * [showGeneralDialog], which allows for customization of the dialog popup. /// * Future showDialog({ @required BuildContext context, @@ -639,10 +596,25 @@ Future showDialog({ WidgetBuilder builder, }) { assert(child == null || builder == null); - return Navigator.of(context, rootNavigator: true).push(new _DialogRoute( - child: child ?? new Builder(builder: builder), - theme: Theme.of(context, shadowThemeOnly: true), + return showGeneralDialog( + context: context, + pageBuilder: (BuildContext buildContext, Animation animation, Animation secondaryAnimation) { + final ThemeData theme = Theme.of(context, shadowThemeOnly: true); + final Widget pageChild = child ?? new Builder(builder: builder); + return new SafeArea( + child: new Builder( + builder: (BuildContext context) { + return theme != null + ? new Theme(data: theme, child: pageChild) + : pageChild; + } + ), + ); + }, barrierDismissible: barrierDismissible, barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, - )); -} + barrierColor: Colors.black54, + transitionDuration: const Duration(milliseconds: 150), + transitionBuilder: _buildMaterialDialogTransitions, + ); +} \ No newline at end of file diff --git a/packages/flutter/lib/src/widgets/pages.dart b/packages/flutter/lib/src/widgets/pages.dart index af498d57bfa..f79ee0c45c8 100644 --- a/packages/flutter/lib/src/widgets/pages.dart +++ b/packages/flutter/lib/src/widgets/pages.dart @@ -44,18 +44,6 @@ abstract class PageRoute extends ModalRoute { } } -/// Signature for the [PageRouteBuilder] function that builds the route's -/// primary contents. -/// -/// See [ModalRoute.buildPage] for complete definition of the parameters. -typedef Widget RoutePageBuilder(BuildContext context, Animation animation, Animation secondaryAnimation); - -/// Signature for the [PageRouteBuilder] function that builds the route's -/// transitions. -/// -/// See [ModalRoute.buildTransitions] for complete definition of the parameters. -typedef Widget RouteTransitionsBuilder(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child); - Widget _defaultTransitionsBuilder(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { return child; } diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart index 27c90ae2b17..aaa4c77d665 100644 --- a/packages/flutter/lib/src/widgets/routes.dart +++ b/packages/flutter/lib/src/widgets/routes.dart @@ -1379,3 +1379,143 @@ abstract class RouteAware { /// longer visible. void didPushNext() { } } + +class _DialogRoute extends PopupRoute { + _DialogRoute({ + @required RoutePageBuilder pageBuilder, + bool barrierDismissible = true, + String barrierLabel, + Color barrierColor = const Color(0x80000000), + Duration transitionDuration = const Duration(milliseconds: 200), + RouteTransitionsBuilder transitionBuilder, + RouteSettings settings, + }) : assert(barrierDismissible != null), + _pageBuilder = pageBuilder, + _barrierDismissible = barrierDismissible, + _barrierLabel = barrierLabel, + _barrierColor = barrierColor, + _transitionDuration = transitionDuration, + _transitionBuilder = transitionBuilder, + super(settings: settings); + + final RoutePageBuilder _pageBuilder; + + @override + bool get barrierDismissible => _barrierDismissible; + final bool _barrierDismissible; + + @override + String get barrierLabel => _barrierLabel; + final String _barrierLabel; + + @override + Color get barrierColor => _barrierColor; + final Color _barrierColor; + + @override + Duration get transitionDuration => _transitionDuration; + final Duration _transitionDuration; + + final RouteTransitionsBuilder _transitionBuilder; + + @override + Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { + return new Semantics( + child: _pageBuilder(context, animation, secondaryAnimation), + scopesRoute: true, + explicitChildNodes: true, + ); + } + + @override + Widget buildTransitions(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + if (_transitionBuilder == null) { + return new FadeTransition( + opacity: new CurvedAnimation( + parent: animation, + curve: Curves.linear, + ), + child: child); + } // Some default transition + return _transitionBuilder(context, animation, secondaryAnimation, child); + } +} + +/// Displays a dialog above the current contents of the app. +/// +/// This function allows for customization of aspects of the dialog popup. +/// +/// This function takes a `pageBuilder` which is used to build the primary +/// content of the route (typically a dialog widget). Content below the dialog +/// is dimmed with a [ModalBarrier]. The widget returned by the `pageBuilder` +/// does not share a context with the location that `showGeneralDialog` is +/// originally called from. Use a [StatefulBuilder] or a custom +/// [StatefulWidget] if the dialog needs to update dynamically. The +/// `pageBuilder` argument can not be null. +/// +/// The `context` argument is used to look up the [Navigator] for the dialog. +/// It is only used when the method is called. Its corresponding widget can +/// be safely removed from the tree before the dialog is closed. +/// +/// The `barrierDismissible` argument is used to determine whether this route +/// can be dismissed by tapping the modal barrier. This argument defaults +/// to true. If `barrierDismissible` is true, a non-null `barrierLabel` must be +/// provided. +/// +/// The `barrierLabel` argument is the semantic label used for a dismissible +/// barrier. This argument defaults to "Dismiss". +/// +/// The `barrierColor` argument is the color used for the modal barrier. This +/// argument defaults to `Color(0x80000000)`. +/// +/// The `transitionDuration` argument is used to determine how long it takes +/// for the route to arrive on or leave off the screen. This argument defaults +/// to 200 milliseconds. +/// +/// The `transitionBuilder` argument is used to define how the route arrives on +/// and leaves off the screen. By default, the transition is a linear fade of +/// the page's contents. +/// +/// Returns a [Future] that resolves to the value (if any) that was passed to +/// [Navigator.pop] when the dialog was closed. +/// +/// The dialog route created by this method is pushed to the root navigator. +/// If the application has multiple [Navigator] objects, it may be necessary to +/// call `Navigator.of(context, rootNavigator: true).pop(result)` to close the +/// dialog rather than just `Navigator.pop(context, result)`. +/// +/// See also: +/// * [showDialog], which displays a Material-style dialog. +/// * [showCupertinoDialog], which displays an iOS-style dialog. +Future showGeneralDialog({ + @required BuildContext context, + @required RoutePageBuilder pageBuilder, + bool barrierDismissible, + String barrierLabel, + Color barrierColor, + Duration transitionDuration, + RouteTransitionsBuilder transitionBuilder, +}) { + assert(pageBuilder != null); + assert(!barrierDismissible || barrierLabel != null); + return Navigator.of(context, rootNavigator: true).push(new _DialogRoute( + pageBuilder: pageBuilder, + barrierDismissible: barrierDismissible, + barrierLabel: barrierLabel, + barrierColor: barrierColor, + transitionDuration: transitionDuration, + transitionBuilder: transitionBuilder, + )); +} + +/// Signature for the function that builds a route's primary contents. +/// Used in [PageRouteBuilder] and [showGeneralDialog]. +/// +/// See [ModalRoute.buildPage] for complete definition of the parameters. +typedef Widget RoutePageBuilder(BuildContext context, Animation animation, Animation secondaryAnimation); + +/// Signature for the function that builds a route's transitions. +/// Used in [PageRouteBuilder] and [showGeneralDialog]. +/// +/// See [ModalRoute.buildTransitions] for complete definition of the parameters. +typedef Widget RouteTransitionsBuilder(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child); diff --git a/packages/flutter/test/cupertino/dialog_test.dart b/packages/flutter/test/cupertino/dialog_test.dart index 74ff46ba08f..bdb2f18a537 100644 --- a/packages/flutter/test/cupertino/dialog_test.dart +++ b/packages/flutter/test/cupertino/dialog_test.dart @@ -332,6 +332,176 @@ void main() { expect(scrollController.offset, 0.0); expect(find.widgetWithText(CupertinoDialogAction, 'One'), findsNothing); }); + + testWidgets('ScaleTransition animation for showCupertinoDialog()', (WidgetTester tester) async { + await tester.pumpWidget( + new CupertinoApp( + home: new Center( + child: new Builder( + builder: (BuildContext context) { + return new CupertinoButton( + onPressed: () { + showCupertinoDialog( + context: context, + builder: (BuildContext context) { + return new CupertinoAlertDialog( + title: const Text('The title'), + content: const Text('The content'), + actions: [ + const CupertinoDialogAction( + child: Text('Cancel'), + ), + new CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Delete'), + ), + ], + ); + }, + ); + }, + child: const Text('Go'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + + // Enter animation. + await tester.pump(); + Transform transform = tester.widget(find.byType(Transform)); + expect(transform.transform[0], closeTo(1.2, 0.01)); + + await tester.pump(const Duration(milliseconds: 50)); + transform = tester.widget(find.byType(Transform)); + expect(transform.transform[0], closeTo(1.182, 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transform = tester.widget(find.byType(Transform)); + expect(transform.transform[0], closeTo(1.108, 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transform = tester.widget(find.byType(Transform)); + expect(transform.transform[0], closeTo(1.044, 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transform = tester.widget(find.byType(Transform)); + expect(transform.transform[0], closeTo(1.015, 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transform = tester.widget(find.byType(Transform)); + expect(transform.transform[0], closeTo(1.003, 0.001)); + + await tester.pump(const Duration(milliseconds: 50)); + transform = tester.widget(find.byType(Transform)); + expect(transform.transform[0], closeTo(1.000, 0.001)); + + await tester.tap(find.text('Delete')); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // No scaling on exit animation. + expect(find.byType(Transform), findsNothing); + }); + + testWidgets('FadeTransition animation for showCupertinoDialog()', (WidgetTester tester) async { + await tester.pumpWidget( + new CupertinoApp( + home: new Center( + child: new Builder( + builder: (BuildContext context) { + return new CupertinoButton( + onPressed: () { + showCupertinoDialog( + context: context, + builder: (BuildContext context) { + return new CupertinoAlertDialog( + title: const Text('The title'), + content: const Text('The content'), + actions: [ + const CupertinoDialogAction( + child: Text('Cancel'), + ), + new CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Delete'), + ), + ], + ); + }, + ); + }, + child: const Text('Go'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Go')); + + // Enter animation. + await tester.pump(); + FadeTransition transition = tester.firstWidget(find.byType(FadeTransition)); + + await tester.pump(const Duration(milliseconds: 25)); + transition = tester.firstWidget(find.byType(FadeTransition)); + expect(transition.opacity.value, closeTo(0.10, 0.001)); + + await tester.pump(const Duration(milliseconds: 25)); + transition = tester.firstWidget(find.byType(FadeTransition)); + expect(transition.opacity.value, closeTo(0.156, 0.001)); + + await tester.pump(const Duration(milliseconds: 25)); + transition = tester.firstWidget(find.byType(FadeTransition)); + expect(transition.opacity.value, closeTo(0.324, 0.001)); + + await tester.pump(const Duration(milliseconds: 25)); + transition = tester.firstWidget(find.byType(FadeTransition)); + expect(transition.opacity.value, closeTo(0.606, 0.001)); + + await tester.pump(const Duration(milliseconds: 25)); + transition = tester.firstWidget(find.byType(FadeTransition)); + expect(transition.opacity.value, closeTo(1.0, 0.001)); + + await tester.tap(find.text('Delete')); + + // Exit animation, look at reverse FadeTransition. + await tester.pump(const Duration(milliseconds: 25)); + transition = tester.widgetList(find.byType(FadeTransition)).elementAt(1); + expect(transition.opacity.value, closeTo(0.358, 0.001)); + + await tester.pump(const Duration(milliseconds: 25)); + transition = tester.widgetList(find.byType(FadeTransition)).elementAt(1); + expect(transition.opacity.value, closeTo(0.231, 0.001)); + + await tester.pump(const Duration(milliseconds: 25)); + transition = tester.widgetList(find.byType(FadeTransition)).elementAt(1); + expect(transition.opacity.value, closeTo(0.128, 0.001)); + + await tester.pump(const Duration(milliseconds: 25)); + transition = tester.widgetList(find.byType(FadeTransition)).elementAt(1); + expect(transition.opacity.value, closeTo(0.056, 0.001)); + + await tester.pump(const Duration(milliseconds: 25)); + transition = tester.widgetList(find.byType(FadeTransition)).elementAt(1); + expect(transition.opacity.value, closeTo(0.013, 0.001)); + + await tester.pump(const Duration(milliseconds: 25)); + transition = tester.widgetList(find.byType(FadeTransition)).elementAt(1); + expect(transition.opacity.value, closeTo(0.0, 0.001)); + }); } Widget boilerplate(Widget child) {