From 0eaeb0d1c5dc8b6d705828e7eebe0fc16b47419b Mon Sep 17 00:00:00 2001 From: Nguyen Phuc Loi Date: Sat, 7 Sep 2024 01:42:36 +0700 Subject: [PATCH] Support custom transition duration for `DialogRoute`, `CupertinoDialogRoute` and show dialog methods. (#154048) Currently we don't support custom transition duration for `DialogRoute`, `CupertinoDialogRoute` and show dialog methods , This PR will to support that. --- packages/flutter/lib/src/cupertino/route.dart | 16 +- packages/flutter/lib/src/material/dialog.dart | 23 +- .../flutter/test/cupertino/route_test.dart | 52 +++- .../flutter/test/material/dialog_test.dart | 101 +++++++- .../flutter/test/widgets/routes_test.dart | 232 +++++++++++++++++- 5 files changed, 415 insertions(+), 9 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/route.dart b/packages/flutter/lib/src/cupertino/route.dart index 51f650c6ee6..5036502fedd 100644 --- a/packages/flutter/lib/src/cupertino/route.dart +++ b/packages/flutter/lib/src/cupertino/route.dart @@ -57,6 +57,9 @@ const Color kCupertinoModalBarrierColor = CupertinoDynamicColor.withBrightness( // The duration of the transition used when a modal popup is shown. const Duration _kModalPopupTransitionDuration = Duration(milliseconds: 335); +// The transition duration used for [CupertinoDialogRoute] transitions. +const Duration _kCupertinoDialogRouteTransitionDuration = Duration(milliseconds: 250); + // Offset from offscreen to the right to fully on screen. final Animatable _kRightMiddleTween = Tween( begin: const Offset(1.0, 0.0), @@ -1269,6 +1272,10 @@ Widget _buildCupertinoDialogTransitions(BuildContext context, Animation /// By default, `useRootNavigator` is `true` and the dialog route created by /// this method is pushed to the root navigator. /// +/// the `transitionDuration` argument is used to specify the duration of +/// the dialog's entrance and exit animations. If it's not provided or `null`, +/// then it uses the default value as set by [CupertinoDialogRoute]. +/// /// {@macro flutter.widgets.RawDialogRoute} /// /// If the application has multiple [Navigator] objects, it may be necessary to @@ -1313,6 +1320,7 @@ Future showCupertinoDialog({ bool barrierDismissible = false, RouteSettings? routeSettings, Offset? anchorPoint, + Duration? transitionDuration, }) { return Navigator.of(context, rootNavigator: useRootNavigator).push(CupertinoDialogRoute( @@ -1323,6 +1331,7 @@ Future showCupertinoDialog({ barrierColor: CupertinoDynamicColor.resolve(kCupertinoModalBarrierColor, context), settings: routeSettings, anchorPoint: anchorPoint, + transitionDuration: transitionDuration, )); } @@ -1350,6 +1359,10 @@ Future showCupertinoDialog({ /// barrier that darkens everything below the dialog. If `null`, then /// [CupertinoDynamicColor.resolve] is used to compute the modal color. /// +/// The `transitionDuration` argument is used to specify the duration of +/// the dialog's entrance and exit animations. If it's not provided or `null`, +/// then the default duration `Duration(milliseconds: 250)` is used. +/// /// The `settings` argument define the settings for this route. See /// [RouteSettings] for details. /// @@ -1372,7 +1385,7 @@ class CupertinoDialogRoute extends RawDialogRoute { Color? barrierColor, String? barrierLabel, // This transition duration was eyeballed comparing with iOS - super.transitionDuration = const Duration(milliseconds: 250), + Duration? transitionDuration, this.transitionBuilder, super.settings, super.requestFocus, @@ -1384,6 +1397,7 @@ class CupertinoDialogRoute extends RawDialogRoute { transitionBuilder: transitionBuilder ?? _buildCupertinoDialogTransitions, barrierLabel: barrierLabel ?? CupertinoLocalizations.of(context).modalBarrierDismissLabel, barrierColor: barrierColor ?? CupertinoDynamicColor.resolve(kCupertinoModalBarrierColor, context), + transitionDuration: transitionDuration ?? _kCupertinoDialogRouteTransitionDuration, ); /// Custom transition builder diff --git a/packages/flutter/lib/src/material/dialog.dart b/packages/flutter/lib/src/material/dialog.dart index dc52b0cdf10..6736666dd55 100644 --- a/packages/flutter/lib/src/material/dialog.dart +++ b/packages/flutter/lib/src/material/dialog.dart @@ -31,6 +31,9 @@ import 'theme_data.dart'; const EdgeInsets _defaultInsetPadding = EdgeInsets.symmetric(horizontal: 40.0, vertical: 24.0); +// The transition duration used for [DialogRoute] transitions. +const Duration _kDialogRouteTransitionDuration = Duration(milliseconds: 150); + /// A Material Design dialog. /// /// This dialog widget does not have any opinion about the contents of the @@ -1345,6 +1348,10 @@ Widget _buildMaterialDialogTransitions(BuildContext context, Animation a /// field from `DialogThemeData` is used. If that is `null` the default color /// `Colors.black54` is used. /// +/// the `transitionDuration` argument is used to specify the duration of +/// the dialog's entrance and exit animations. If it's not provided or `null`, +/// then it uses the default value as set by [DialogRoute]. +/// /// The `useSafeArea` argument is used to indicate if the dialog should only /// display in 'safe' areas of the screen not used by the operating system /// (see [SafeArea] for more details). It is `true` by default, which means @@ -1428,6 +1435,7 @@ Future showDialog({ RouteSettings? routeSettings, Offset? anchorPoint, TraversalEdgeBehavior? traversalEdgeBehavior, + Duration? transitionDuration, }) { assert(_debugIsActive(context)); assert(debugCheckHasMaterialLocalizations(context)); @@ -1454,6 +1462,7 @@ Future showDialog({ themes: themes, anchorPoint: anchorPoint, traversalEdgeBehavior: traversalEdgeBehavior ?? TraversalEdgeBehavior.closedLoop, + transitionDuration: transitionDuration, )); } @@ -1465,6 +1474,10 @@ Future showDialog({ /// /// On Cupertino platforms, [barrierColor], [useSafeArea], and /// [traversalEdgeBehavior] are ignored. +/// +/// The `transitionDuration` argument is used to specify the duration of +/// the dialog's entrance and exit animations. If it's not provided or `null`, +/// then it uses the default value as set by [DialogRoute] or [CupertinoDialogRoute]. Future showAdaptiveDialog({ required BuildContext context, required WidgetBuilder builder, @@ -1476,6 +1489,7 @@ Future showAdaptiveDialog({ RouteSettings? routeSettings, Offset? anchorPoint, TraversalEdgeBehavior? traversalEdgeBehavior, + Duration? transitionDuration, }) { final ThemeData theme = Theme.of(context); switch (theme.platform) { @@ -1494,6 +1508,7 @@ Future showAdaptiveDialog({ routeSettings: routeSettings, anchorPoint: anchorPoint, traversalEdgeBehavior: traversalEdgeBehavior, + transitionDuration: transitionDuration, ); case TargetPlatform.iOS: case TargetPlatform.macOS: @@ -1505,6 +1520,7 @@ Future showAdaptiveDialog({ useRootNavigator: useRootNavigator, anchorPoint: anchorPoint, routeSettings: routeSettings, + transitionDuration: transitionDuration, ); } } @@ -1552,6 +1568,10 @@ bool _debugIsActive(BuildContext context) { /// barrier that darkens everything below the dialog. If `null`, the default /// color `Colors.black54` is used. /// +/// the `transitionDuration` argument is used to specify the duration of +/// the dialog's entrance and exit animations. If it's not provided or `null`, +/// then the default duration `Duration(milliseconds: 150)` is used. +/// /// The `useSafeArea` argument is used to indicate if the dialog should only /// display in 'safe' areas of the screen not used by the operating system /// (see [SafeArea] for more details). It is `true` by default, which means @@ -1582,6 +1602,7 @@ class DialogRoute extends RawDialogRoute { super.barrierDismissible, String? barrierLabel, bool useSafeArea = true, + Duration? transitionDuration, super.settings, super.requestFocus, super.anchorPoint, @@ -1596,8 +1617,8 @@ class DialogRoute extends RawDialogRoute { return dialog; }, barrierLabel: barrierLabel ?? MaterialLocalizations.of(context).modalBarrierDismissLabel, - transitionDuration: const Duration(milliseconds: 150), transitionBuilder: _buildMaterialDialogTransitions, + transitionDuration: transitionDuration ?? _kDialogRouteTransitionDuration, ); CurvedAnimation? _curvedAnimation; diff --git a/packages/flutter/test/cupertino/route_test.dart b/packages/flutter/test/cupertino/route_test.dart index 182bf0b70d5..6e33a9093a3 100644 --- a/packages/flutter/test/cupertino/route_test.dart +++ b/packages/flutter/test/cupertino/route_test.dart @@ -1883,6 +1883,45 @@ void main() { expect(nestedObserver.dialogCount, 0); }); + testWidgets('showCupertinoDialog - custom transitionDuration', (WidgetTester tester) async { + final DialogObserver rootObserver = DialogObserver(); + final DialogObserver nestedObserver = DialogObserver(); + + await tester.pumpWidget(CupertinoApp( + navigatorObservers: [rootObserver], + home: Navigator( + observers: [nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return PageRouteBuilder( + pageBuilder: (BuildContext context, Animation _, Animation __) { + return GestureDetector( + onTap: () async { + await showCupertinoDialog( + context: context, + transitionDuration: const Duration(milliseconds: 50), + builder: (BuildContext context) => const SizedBox(), + ); + }, + child: const Text('tap'), + ); + }, + ); + }, + ), + )); + + // Open the dialog. + await tester.tap(find.text('tap')); + await tester.pump(); + + expect(rootObserver.dialogCount, 1); + expect(nestedObserver.dialogCount, 0); + expect(rootObserver.dialogRoutes.length, equals(1)); + final ModalRoute route = rootObserver.dialogRoutes.last; + expect(route is CupertinoDialogRoute, true); + expect(route.transitionDuration.inMilliseconds, 50); + }); + testWidgets('showCupertinoDialog uses nested navigator if useRootNavigator is false', (WidgetTester tester) async { final DialogObserver rootObserver = DialogObserver(); final DialogObserver nestedObserver = DialogObserver(); @@ -2932,15 +2971,24 @@ class PopupObserver extends NavigatorObserver { } class DialogObserver extends NavigatorObserver { - int dialogCount = 0; + final List> dialogRoutes = >[]; + int get dialogCount => dialogRoutes.length; @override void didPush(Route route, Route? previousRoute) { if (route is CupertinoDialogRoute) { - dialogCount++; + dialogRoutes.add(route); } super.didPush(route, previousRoute); } + + @override + void didPop(Route route, Route? previousRoute) { + if (route is CupertinoDialogRoute) { + dialogRoutes.removeLast(); + } + super.didPop(route, previousRoute); + } } class RouteSettingsObserver extends NavigatorObserver { diff --git a/packages/flutter/test/material/dialog_test.dart b/packages/flutter/test/material/dialog_test.dart index 9c0db0b2b8b..f08f4c69938 100644 --- a/packages/flutter/test/material/dialog_test.dart +++ b/packages/flutter/test/material/dialog_test.dart @@ -2533,6 +2533,50 @@ void main() { expect(currentRouteSetting.name, '/'); }); + testWidgets('showDialog - custom transitionDuration', (WidgetTester tester) async { + final DialogObserver rootObserver = DialogObserver(); + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [rootObserver], + home: Material( + child: Builder( + builder: (BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('X'), + onPressed: () { + showDialog( + context: context, + transitionDuration: const Duration(milliseconds: 50), + builder: (BuildContext context) { + return const AlertDialog( + title: Text('Title'), + content: Text('Y'), + actions: [], + ); + }, + ); + }, + ), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); + + expect(rootObserver.dialogRoutes.length, equals(1)); + final ModalRoute route = rootObserver.dialogRoutes.last; + expect(route is DialogRoute, true); + expect(route.barrierDismissible, isNotNull); + expect(route.barrierColor, isNotNull); + expect(route.transitionDuration, isNotNull); + expect(route.transitionDuration.inMilliseconds, 50); + }); + testWidgets('showDialog - custom barrierLabel', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); @@ -2829,6 +2873,50 @@ void main() { expect(find.text('Dialog2'), findsOneWidget); }); + testWidgets('showAdaptiveDialog - custom transitionDuration', (WidgetTester tester) async { + final DialogObserver rootObserver = DialogObserver(); + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [rootObserver], + home: Material( + child: Builder( + builder: (BuildContext context) { + return Center( + child: ElevatedButton( + child: const Text('X'), + onPressed: () { + showAdaptiveDialog( + context: context, + transitionDuration: const Duration(milliseconds: 50), + builder: (BuildContext context) { + return const AlertDialog( + title: Text('Title'), + content: Text('Y'), + actions: [], + ); + }, + ); + }, + ), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); + + expect(rootObserver.dialogRoutes.length, equals(1)); + final ModalRoute route = rootObserver.dialogRoutes.last; + expect(route is DialogRoute, true); + expect(route.barrierDismissible, isNotNull); + expect(route.barrierColor, isNotNull); + expect(route.transitionDuration, isNotNull); + expect(route.transitionDuration.inMilliseconds, 50); + }); + testWidgets('Uses open focus traversal when overridden', (WidgetTester tester) async { final FocusNode okNode = FocusNode(); addTearDown(okNode.dispose); @@ -3002,15 +3090,24 @@ class _RestorableDialogTestWidget extends StatelessWidget { } class DialogObserver extends NavigatorObserver { - int dialogCount = 0; + final List> dialogRoutes = >[]; + int get dialogCount => dialogRoutes.length; @override void didPush(Route route, Route? previousRoute) { if (route is DialogRoute) { - dialogCount++; + dialogRoutes.add(route); } super.didPush(route, previousRoute); } + + @override + void didPop(Route route, Route? previousRoute) { + if (route is DialogRoute) { + dialogRoutes.removeLast(); + } + super.didPop(route, previousRoute); + } } class _ClosureNavigatorObserver extends NavigatorObserver { diff --git a/packages/flutter/test/widgets/routes_test.dart b/packages/flutter/test/widgets/routes_test.dart index a058ed1bd37..27da101fea0 100644 --- a/packages/flutter/test/widgets/routes_test.dart +++ b/packages/flutter/test/widgets/routes_test.dart @@ -5,6 +5,7 @@ import 'dart:collection'; import 'dart:ui'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -1920,6 +1921,233 @@ void main() { }); }); + group('DialogRoute', () { + testWidgets('DialogRoute - default transitionDuration', (WidgetTester tester) async { + final GlobalKey containerKey = GlobalKey(); + final DialogObserver rootObserver = DialogObserver(); + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [rootObserver], + home: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showDialog( + context: context, + builder: (BuildContext innerContext) { + return Container( + key: containerKey, + color: Colors.green, + ); + }, + ); + }, + child: const Text('Open dialog'), + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byKey(containerKey), findsOneWidget); + + expect(rootObserver.dialogCount, 1); + final ModalRoute route = rootObserver.dialogRoutes.last; + expect(route is RawDialogRoute, true); + expect(route.transitionDuration.inMilliseconds, 150); + + // Pop the new route. + tester.state(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: 75)); + expect(find.byKey(containerKey), findsOneWidget); + + // Container should be present at the very end of the transition. + await tester.pump(const Duration(milliseconds: 75)); + expect(find.byKey(containerKey), findsOneWidget); + + // Container have transitioned out after 150ms. + await tester.pump(const Duration(milliseconds: 1)); + expect(find.byKey(containerKey), findsNothing); + }); + + testWidgets('DialogRoute - custom transitionDuration', (WidgetTester tester) async { + final GlobalKey containerKey = GlobalKey(); + final DialogObserver rootObserver = DialogObserver(); + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [rootObserver], + home: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showDialog( + context: context, + transitionDuration: const Duration(milliseconds: 300), + builder: (BuildContext innerContext) { + return Container( + key: containerKey, + color: Colors.green, + ); + }, + ); + }, + child: const Text('Open dialog'), + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byKey(containerKey), findsOneWidget); + + expect(rootObserver.dialogCount, 1); + final ModalRoute route = rootObserver.dialogRoutes.last; + expect(route is RawDialogRoute, true); + expect(route.transitionDuration.inMilliseconds, 300); + + // Pop the new route. + tester.state(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: 150)); + expect(find.byKey(containerKey), findsOneWidget); + + // Container should be present at the very end of the transition. + await tester.pump(const Duration(milliseconds: 150)); + expect(find.byKey(containerKey), findsOneWidget); + + // Container have transitioned out after 300ms. + await tester.pump(const Duration(milliseconds: 1)); + expect(find.byKey(containerKey), findsNothing); + }); + }); + + group('CupertinoDialogRoute', () { + testWidgets('CupertinoDialogRoute - default transitionDuration', (WidgetTester tester) async { + final GlobalKey containerKey = GlobalKey(); + final DialogObserver rootObserver = DialogObserver(); + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [rootObserver], + theme: ThemeData(platform: TargetPlatform.iOS), + home: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showCupertinoDialog( + context: context, + builder: (BuildContext innerContext) { + return Container( + key: containerKey, + color: Colors.green, + ); + }, + ); + }, + child: const Text('Open dialog'), + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byKey(containerKey), findsOneWidget); + + expect(rootObserver.dialogCount, 1); + final ModalRoute route = rootObserver.dialogRoutes.last; + expect(route is RawDialogRoute, true); + expect(route.transitionDuration.inMilliseconds, 250); + + // Pop the new route. + tester.state(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: 125)); + expect(find.byKey(containerKey), findsOneWidget); + + // Container should be present at the very end of the transition. + await tester.pump(const Duration(milliseconds: 125)); + expect(find.byKey(containerKey), findsOneWidget); + + // Container have transitioned out after 250ms. + await tester.pump(const Duration(milliseconds: 1)); + expect(find.byKey(containerKey), findsNothing); + }); + + testWidgets('CupertinoDialogRoute - custom transitionDuration', (WidgetTester tester) async { + final GlobalKey containerKey = GlobalKey(); + final DialogObserver rootObserver = DialogObserver(); + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [rootObserver], + home: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showDialog( + context: context, + transitionDuration: const Duration(milliseconds: 100), + builder: (BuildContext innerContext) { + return Container( + key: containerKey, + color: Colors.green, + ); + }, + ); + }, + child: const Text('Open dialog'), + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byKey(containerKey), findsOneWidget); + + expect(rootObserver.dialogCount, 1); + final ModalRoute route = rootObserver.dialogRoutes.last; + expect(route is RawDialogRoute, true); + expect(route.transitionDuration.inMilliseconds, 100); + + // Pop the new route. + tester.state(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: 50)); + expect(find.byKey(containerKey), findsOneWidget); + + // Container should be present at the very end of the transition. + await tester.pump(const Duration(milliseconds: 50)); + expect(find.byKey(containerKey), findsOneWidget); + + // Container have transitioned out after 100ms. + await tester.pump(const Duration(milliseconds: 1)); + expect(find.byKey(containerKey), findsNothing); + }); + }); + testWidgets('can be dismissed with escape keyboard shortcut', (WidgetTester tester) async { final GlobalKey navigatorKey = GlobalKey(); await tester.pumpWidget(MaterialApp( @@ -2155,13 +2383,12 @@ class TestPageRouteBuilder extends PageRouteBuilder { class DialogObserver extends NavigatorObserver { final List> dialogRoutes = >[]; - int dialogCount = 0; + int get dialogCount => dialogRoutes.length; @override void didPush(Route route, Route? previousRoute) { if (route is RawDialogRoute) { dialogRoutes.add(route); - dialogCount++; } super.didPush(route, previousRoute); } @@ -2170,7 +2397,6 @@ class DialogObserver extends NavigatorObserver { void didPop(Route route, Route? previousRoute) { if (route is RawDialogRoute) { dialogRoutes.removeLast(); - dialogCount--; } super.didPop(route, previousRoute); }