From fc917c7184291a7f32ec532d445f179014885a2b Mon Sep 17 00:00:00 2001 From: Taha Tesser Date: Tue, 21 Nov 2023 01:24:41 +0200 Subject: [PATCH] [Reland] Introduce `AnimationStyle` (#138721) This PR introduces `AnimationStyle`, it is used to override default animation curves and durations in several widgets. fixes [Add the ability to customize MaterialApp theme animation duration](https://github.com/flutter/flutter/issues/78372) fixes [Allow customization of showMenu transition animation curves and duration](https://github.com/flutter/flutter/issues/135638) fixes [`AnimationStyle.noAnimation` needs to replace `AnimatedTheme` with just `Theme` in the `MaterialApp`](https://github.com/flutter/flutter/issues/138618) Here is an example where popup menu curve and transition duration is overridden: ```dart popUpAnimationStyle: AnimationStyle( curve: Easing.emphasizedAccelerate, duration: Durations.medium4, ), ``` Set `AnimationStyle.noAnimation` to disable animation. ```dart return MaterialApp( themeAnimationStyle: AnimationStyle.noAnimation, ``` ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/wiki/Tree-hygiene#overview [Tree Hygiene]: https://github.com/flutter/flutter/wiki/Tree-hygiene [test-exempt]: https://github.com/flutter/flutter/wiki/Tree-hygiene#tests [Flutter Style Guide]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo [Features we expect every widget to implement]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#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/wiki/Tree-hygiene#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/wiki/Chat --- examples/api/lib/material/app/app.0.dart | 87 ++++++++++++ .../lib/material/popup_menu/popup_menu.0.dart | 7 +- .../lib/material/popup_menu/popup_menu.1.dart | 7 +- .../lib/material/popup_menu/popup_menu.2.dart | 129 ++++++++++++++++++ .../api/test/material/app/app.0_test.dart | 76 +++++++++++ .../popup_menu/popup_menu.2_test.dart | 63 +++++++++ packages/flutter/lib/animation.dart | 1 + .../lib/src/animation/animation_style.dart | 110 +++++++++++++++ packages/flutter/lib/src/material/app.dart | 83 +++++++---- .../flutter/lib/src/material/popup_menu.dart | 46 ++++++- .../test/animation/animation_style_test.dart | 93 +++++++++++++ packages/flutter/test/material/app_test.dart | 77 +++++++++++ .../test/material/popup_menu_test.dart | 84 ++++++++++++ 13 files changed, 826 insertions(+), 37 deletions(-) create mode 100644 examples/api/lib/material/app/app.0.dart create mode 100644 examples/api/lib/material/popup_menu/popup_menu.2.dart create mode 100644 examples/api/test/material/app/app.0_test.dart create mode 100644 examples/api/test/material/popup_menu/popup_menu.2_test.dart create mode 100644 packages/flutter/lib/src/animation/animation_style.dart create mode 100644 packages/flutter/test/animation/animation_style_test.dart diff --git a/examples/api/lib/material/app/app.0.dart b/examples/api/lib/material/app/app.0.dart new file mode 100644 index 0000000000..5df0bdee35 --- /dev/null +++ b/examples/api/lib/material/app/app.0.dart @@ -0,0 +1,87 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +/// Flutter code sample for [MaterialApp]. + +void main() { + runApp(const MaterialAppExample()); +} + +enum AnimationStyles { defaultStyle, custom, none } +const List<(AnimationStyles, String)> animationStyleSegments = <(AnimationStyles, String)>[ + (AnimationStyles.defaultStyle, 'Default'), + (AnimationStyles.custom, 'Custom'), + (AnimationStyles.none, 'None'), +]; + +class MaterialAppExample extends StatefulWidget { + const MaterialAppExample({super.key}); + + @override + State createState() => _MaterialAppExampleState(); +} + +class _MaterialAppExampleState extends State { + Set _animationStyleSelection = {AnimationStyles.defaultStyle}; + AnimationStyle? _animationStyle; + bool isDarkTheme = false; + + @override + Widget build(BuildContext context) { + return MaterialApp( + themeAnimationStyle: _animationStyle, + themeMode: isDarkTheme ? ThemeMode.dark : ThemeMode.light, + theme: ThemeData(colorSchemeSeed: Colors.green), + darkTheme: ThemeData( + colorSchemeSeed: Colors.green, + brightness: Brightness.dark, + ), + home: Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SegmentedButton( + selected: _animationStyleSelection, + onSelectionChanged: (Set styles) { + setState(() { + _animationStyleSelection = styles; + switch (styles.first) { + case AnimationStyles.defaultStyle: + _animationStyle = null; + case AnimationStyles.custom: + _animationStyle = AnimationStyle( + curve: Easing.emphasizedAccelerate, + duration: const Duration(seconds: 1), + ); + case AnimationStyles.none: + _animationStyle = AnimationStyle.noAnimation; + } + }); + }, + segments: animationStyleSegments + .map>(((AnimationStyles, String) shirt) { + return ButtonSegment(value: shirt.$1, label: Text(shirt.$2)); + }) + .toList(), + ), + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: () { + setState(() { + isDarkTheme = !isDarkTheme; + }); + }, + icon: Icon(isDarkTheme ? Icons.wb_sunny : Icons.nightlight_round), + label: const Text('Switch Theme Mode'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/examples/api/lib/material/popup_menu/popup_menu.0.dart b/examples/api/lib/material/popup_menu/popup_menu.0.dart index 47f7c820dc..cfdc80ac7f 100644 --- a/examples/api/lib/material/popup_menu/popup_menu.0.dart +++ b/examples/api/lib/material/popup_menu/popup_menu.0.dart @@ -30,7 +30,7 @@ class PopupMenuExample extends StatefulWidget { } class _PopupMenuExampleState extends State { - SampleItem? selectedMenu; + SampleItem? selectedItem; @override Widget build(BuildContext context) { @@ -38,11 +38,10 @@ class _PopupMenuExampleState extends State { appBar: AppBar(title: const Text('PopupMenuButton')), body: Center( child: PopupMenuButton( - initialValue: selectedMenu, - // Callback that sets the selected popup menu item. + initialValue: selectedItem, onSelected: (SampleItem item) { setState(() { - selectedMenu = item; + selectedItem = item; }); }, itemBuilder: (BuildContext context) => >[ diff --git a/examples/api/lib/material/popup_menu/popup_menu.1.dart b/examples/api/lib/material/popup_menu/popup_menu.1.dart index 2a3a4d6a92..a961d4013b 100644 --- a/examples/api/lib/material/popup_menu/popup_menu.1.dart +++ b/examples/api/lib/material/popup_menu/popup_menu.1.dart @@ -31,7 +31,7 @@ class PopupMenuExample extends StatefulWidget { } class _PopupMenuExampleState extends State { - SampleItem? selectedMenu; + SampleItem? selectedItem; @override Widget build(BuildContext context) { @@ -39,11 +39,10 @@ class _PopupMenuExampleState extends State { appBar: AppBar(title: const Text('PopupMenuButton')), body: Center( child: PopupMenuButton( - initialValue: selectedMenu, - // Callback that sets the selected popup menu item. + initialValue: selectedItem, onSelected: (SampleItem item) { setState(() { - selectedMenu = item; + selectedItem = item; }); }, itemBuilder: (BuildContext context) => >[ diff --git a/examples/api/lib/material/popup_menu/popup_menu.2.dart b/examples/api/lib/material/popup_menu/popup_menu.2.dart new file mode 100644 index 0000000000..50947b1073 --- /dev/null +++ b/examples/api/lib/material/popup_menu/popup_menu.2.dart @@ -0,0 +1,129 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +/// Flutter code sample for [PopupMenuButton]. + +void main() => runApp(const PopupMenuApp()); + +class PopupMenuApp extends StatelessWidget { + const PopupMenuApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: PopupMenuExample(), + ); + } +} + +enum AnimationStyles { defaultStyle, custom, none } +const List<(AnimationStyles, String)> animationStyleSegments = <(AnimationStyles, String)>[ + (AnimationStyles.defaultStyle, 'Default'), + (AnimationStyles.custom, 'Custom'), + (AnimationStyles.none, 'None'), +]; + +enum Menu { preview, share, getLink, remove, download } + +class PopupMenuExample extends StatefulWidget { + const PopupMenuExample({super.key}); + + @override + State createState() => _PopupMenuExampleState(); +} + +class _PopupMenuExampleState extends State { + Set _animationStyleSelection = {AnimationStyles.defaultStyle}; + AnimationStyle? _animationStyle; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 50), + child: Align( + alignment: Alignment.topCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SegmentedButton( + selected: _animationStyleSelection, + onSelectionChanged: (Set styles) { + setState(() { + _animationStyleSelection = styles; + switch (styles.first) { + case AnimationStyles.defaultStyle: + _animationStyle = null; + case AnimationStyles.custom: + _animationStyle = AnimationStyle( + curve: Easing.emphasizedDecelerate, + duration: const Duration(seconds: 3), + ); + case AnimationStyles.none: + _animationStyle = AnimationStyle.noAnimation; + } + }); + }, + segments: animationStyleSegments + .map>(((AnimationStyles, String) shirt) { + return ButtonSegment(value: shirt.$1, label: Text(shirt.$2)); + }) + .toList(), + ), + const SizedBox(height: 10), + PopupMenuButton( + popUpAnimationStyle: _animationStyle, + icon: const Icon(Icons.more_vert), + onSelected: (Menu item) { }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: Menu.preview, + child: ListTile( + leading: Icon(Icons.visibility_outlined), + title: Text('Preview'), + ), + ), + const PopupMenuItem( + value: Menu.share, + child: ListTile( + leading: Icon(Icons.share_outlined), + title: Text('Share'), + ), + ), + const PopupMenuItem( + value: Menu.getLink, + child: ListTile( + leading: Icon(Icons.link_outlined), + title: Text('Get link'), + ), + ), + const PopupMenuDivider(), + const PopupMenuItem( + value: Menu.remove, + child: ListTile( + leading: Icon(Icons.delete_outline), + title: Text('Remove'), + ), + ), + const PopupMenuItem( + value: Menu.download, + child: ListTile( + leading: Icon(Icons.download_outlined), + title: Text('Download'), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/examples/api/test/material/app/app.0_test.dart b/examples/api/test/material/app/app.0_test.dart new file mode 100644 index 0000000000..007b95683f --- /dev/null +++ b/examples/api/test/material/app/app.0_test.dart @@ -0,0 +1,76 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/app/app.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Theme animation can be customized using AnimationStyle', (WidgetTester tester) async { + await tester.pumpWidget( + const example.MaterialAppExample(), + ); + + Material getScaffoldMaterial() { + return tester.widget(find.descendant( + of: find.byType(Scaffold), + matching: find.byType(Material).first, + )); + } + + final ThemeData lightTheme = ThemeData(colorSchemeSeed: Colors.green); + final ThemeData darkTheme = ThemeData( + colorSchemeSeed: Colors.green, + brightness: Brightness.dark, + ); + + // Test the default animation. + expect(getScaffoldMaterial().color, lightTheme.colorScheme.background); + + await tester.tap(find.text( 'Switch Theme Mode')); + await tester.pump(); + // Advance the animation by half of the default duration. + await tester.pump(const Duration(milliseconds: 100)); + + // The Scaffold background color is updated. + expect( + getScaffoldMaterial().color, + Color.lerp(lightTheme.colorScheme.background, darkTheme.colorScheme.background, 0.5), + ); + + await tester.pumpAndSettle(); + + // The Scaffold background color is now fully dark. + expect(getScaffoldMaterial().color, darkTheme.colorScheme.background); + + // Test the custom animation curve and duration. + await tester.tap(find.text('Custom')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Switch Theme Mode')); + await tester.pump(); + // Advance the animation by half of the custom duration. + await tester.pump(const Duration(milliseconds: 500)); + + // The Scaffold background color is updated. + expect(getScaffoldMaterial().color, const Color(0xff3c3e3b)); + + await tester.pumpAndSettle(); + + // The Scaffold background color is now fully light. + expect(getScaffoldMaterial().color, lightTheme.colorScheme.background); + + // Test the no animation style. + await tester.tap(find.text('None')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Switch Theme Mode')); + // Advance the animation by only one frame. + await tester.pump(); + + // The Scaffold background color is updated immediately. + expect(getScaffoldMaterial().color, darkTheme.colorScheme.background); + }); +} diff --git a/examples/api/test/material/popup_menu/popup_menu.2_test.dart b/examples/api/test/material/popup_menu/popup_menu.2_test.dart new file mode 100644 index 0000000000..e8e130c7dc --- /dev/null +++ b/examples/api/test/material/popup_menu/popup_menu.2_test.dart @@ -0,0 +1,63 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/popup_menu/popup_menu.2.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Popup animation can be customized using AnimationStyle', (WidgetTester tester) async { + await tester.pumpWidget( + const example.PopupMenuApp(), + ); + + // Test the default popup animation. + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pump(); + // Advance the animation by half of the default duration. + await tester.pump(const Duration(milliseconds: 100)); + + expect(tester.getSize(find.byType(Material).last), within(distance: 0.1, from: const Size(224.0, 130.0))); + + // Let the animation finish. + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byType(Material).last), within(distance: 0.1, from: const Size(224.0, 312.0))); + + // Tap outside the popup menu to close it. + await tester.tapAt(const Offset(1, 1)); + await tester.pumpAndSettle(); + + // Test the custom animation curve and duration. + await tester.tap(find.text('Custom')); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pump(); + // Advance the animation by one third of the custom duration. + await tester.pump(const Duration(milliseconds: 1000)); + + expect(tester.getSize(find.byType(Material).last), within(distance: 0.1, from: const Size(224.0, 312.0))); + + // Let the animation finish. + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byType(Material).last), within(distance: 0.1, from: const Size(224.0, 312.0))); + + // Tap outside the popup menu to close it. + await tester.tapAt(const Offset(1, 1)); + await tester.pumpAndSettle(); + + // Test the no animation style. + await tester.tap(find.text('None')); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.more_vert)); + // Advance the animation by only one frame. + await tester.pump(); + + // The popup menu is shown immediately. + expect(tester.getSize(find.byType(Material).last), within(distance: 0.1, from: const Size(224.0, 312.0))); + }); +} diff --git a/packages/flutter/lib/animation.dart b/packages/flutter/lib/animation.dart index 24255db7ee..5df986c1e1 100644 --- a/packages/flutter/lib/animation.dart +++ b/packages/flutter/lib/animation.dart @@ -165,6 +165,7 @@ export 'package:flutter/scheduler.dart' show TickerCanceled; export 'src/animation/animation.dart'; export 'src/animation/animation_controller.dart'; +export 'src/animation/animation_style.dart'; export 'src/animation/animations.dart'; export 'src/animation/curves.dart'; export 'src/animation/listener_helpers.dart'; diff --git a/packages/flutter/lib/src/animation/animation_style.dart b/packages/flutter/lib/src/animation/animation_style.dart new file mode 100644 index 0000000000..18f95aa246 --- /dev/null +++ b/packages/flutter/lib/src/animation/animation_style.dart @@ -0,0 +1,110 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; + +import 'curves.dart'; +import 'tween.dart'; + +/// Used to override the default parameters of an animation. +/// +/// Currently, this class is used by the following widgets: +/// - [ExpansionTile] +/// - [MaterialApp] +/// - [PopupMenuButton] +/// +/// If [duration] and [reverseDuration] are set to [Duration.zero], the +/// corresponding animation will be disabled. +/// +/// All of the parameters are optional. If no parameters are specified, +/// the default animation will be used. +@immutable +class AnimationStyle with Diagnosticable { + /// Creates an instance of Animation Style class. + AnimationStyle({ + this.curve, + this.duration, + this.reverseCurve, + this.reverseDuration, + }); + + /// Creates an instance of Animation Style class with no animation. + static AnimationStyle noAnimation = AnimationStyle( + duration: Duration.zero, + reverseDuration: Duration.zero, + ); + + /// When specified, the animation will use this curve. + final Curve? curve; + + /// When specified, the animation will use this duration. + final Duration? duration; + + /// When specified, the reverse animation will use this curve. + final Curve? reverseCurve; + + /// When specified, the reverse animation will use this duration. + final Duration? reverseDuration; + + /// Creates a new [AnimationStyle] based on the current selection, with the + /// provided parameters overridden. + AnimationStyle copyWith({ + final Curve? curve, + final Duration? duration, + final Curve? reverseCurve, + final Duration? reverseDuration, + }) { + return AnimationStyle( + curve: curve ?? this.curve, + duration: duration ?? this.duration, + reverseCurve: reverseCurve ?? this.reverseCurve, + reverseDuration: reverseDuration ?? this.reverseDuration, + ); + } + + /// Linearly interpolate between two animation styles. + static AnimationStyle? lerp(AnimationStyle? a, AnimationStyle? b, double t) { + if (identical(a, b)) { + return a; + } + return AnimationStyle( + curve: t < 0.5 ? a?.curve : b?.curve, + duration: t < 0.5 ? a?.duration : b?.duration, + reverseCurve: t < 0.5 ? a?.reverseCurve : b?.reverseCurve, + reverseDuration: t < 0.5 ? a?.reverseDuration : b?.reverseDuration, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is AnimationStyle + && other.curve == curve + && other.duration == duration + && other.reverseCurve == reverseCurve + && other.reverseDuration == reverseDuration; + } + + @override + int get hashCode => Object.hash( + curve, + duration, + reverseCurve, + reverseDuration, + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('curve', curve, defaultValue: null)); + properties.add(DiagnosticsProperty('duration', duration, defaultValue: null)); + properties.add(DiagnosticsProperty('reverseCurve', reverseCurve, defaultValue: null)); + properties.add(DiagnosticsProperty('reverseDuration', reverseDuration, defaultValue: null)); + } +} diff --git a/packages/flutter/lib/src/material/app.dart b/packages/flutter/lib/src/material/app.dart index 7d7d42687e..31561bcedf 100644 --- a/packages/flutter/lib/src/material/app.dart +++ b/packages/flutter/lib/src/material/app.dart @@ -184,6 +184,7 @@ enum ThemeMode { /// ), /// ) /// ``` +/// /// See also: /// /// * [Scaffold], which provides standard app elements like an [AppBar] and a [Drawer]. @@ -246,6 +247,7 @@ class MaterialApp extends StatefulWidget { 'This feature was deprecated after v3.7.0-29.0.pre.' ) this.useInheritedMediaQuery = false, + this.themeAnimationStyle, }) : routeInformationProvider = null, routeInformationParser = null, routerDelegate = null, @@ -296,6 +298,7 @@ class MaterialApp extends StatefulWidget { 'This feature was deprecated after v3.7.0-29.0.pre.' ) this.useInheritedMediaQuery = false, + this.themeAnimationStyle, }) : assert(routerDelegate != null || routerConfig != null), navigatorObservers = null, navigatorKey = null, @@ -753,6 +756,28 @@ class MaterialApp extends StatefulWidget { ) final bool useInheritedMediaQuery; + /// Used to override the theme animation curve and duration. + /// + /// If [AnimationStyle.duration] is provided, it will be used to override + /// the theme animation duration in the underlying [AnimatedTheme] widget. + /// If it is null, then [themeAnimationDuration] will be used. Otherwise, + /// defaults to 200ms. + /// + /// If [AnimationStyle.curve] is provided, it will be used to override + /// the theme animation curve in the underlying [AnimatedTheme] widget. + /// If it is null, then [themeAnimationCurve] will be used. Otherwise, + /// defaults to [Curves.linear]. + /// + /// To disable the theme animation, use [AnimationStyle.noAnimation]. + /// + /// {@tool dartpad} + /// This sample showcases how to override the theme animation curve and + /// duration in the [MaterialApp] widget using [AnimationStyle]. + /// + /// ** See code in examples/api/lib/material/app/app.0.dart ** + /// {@end-tool} + final AnimationStyle? themeAnimationStyle; + @override State createState() => _MaterialAppState(); @@ -930,34 +955,46 @@ class _MaterialAppState extends State { final Color effectiveSelectionColor = theme.textSelectionTheme.selectionColor ?? theme.colorScheme.primary.withOpacity(0.40); final Color effectiveCursorColor = theme.textSelectionTheme.cursorColor ?? theme.colorScheme.primary; + Widget childWidget = child ?? const SizedBox.shrink(); + + if (widget.themeAnimationStyle != AnimationStyle.noAnimation) { + if (widget.builder != null) { + childWidget = Builder( + builder: (BuildContext context) { + // Why are we surrounding a builder with a builder? + // + // The widget.builder may contain code that invokes + // Theme.of(), which should return the theme we selected + // above in AnimatedTheme. However, if we invoke + // widget.builder() directly as the child of AnimatedTheme + // then there is no Context separating them, and the + // widget.builder() will not find the theme. Therefore, we + // surround widget.builder with yet another builder so that + // a context separates them and Theme.of() correctly + // resolves to the theme we passed to AnimatedTheme. + return widget.builder!(context, child); + }, + ); + } + childWidget = AnimatedTheme( + data: theme, + duration: widget.themeAnimationStyle?.duration ?? widget.themeAnimationDuration, + curve: widget.themeAnimationStyle?.curve ?? widget.themeAnimationCurve, + child: childWidget, + ); + } else { + childWidget = Theme( + data: theme, + child: childWidget, + ); + } + return ScaffoldMessenger( key: widget.scaffoldMessengerKey, child: DefaultSelectionStyle( selectionColor: effectiveSelectionColor, cursorColor: effectiveCursorColor, - child: AnimatedTheme( - data: theme, - duration: widget.themeAnimationDuration, - curve: widget.themeAnimationCurve, - child: widget.builder != null - ? Builder( - builder: (BuildContext context) { - // Why are we surrounding a builder with a builder? - // - // The widget.builder may contain code that invokes - // Theme.of(), which should return the theme we selected - // above in AnimatedTheme. However, if we invoke - // widget.builder() directly as the child of AnimatedTheme - // then there is no Context separating them, and the - // widget.builder() will not find the theme. Therefore, we - // surround widget.builder with yet another builder so that - // a context separates them and Theme.of() correctly - // resolves to the theme we passed to AnimatedTheme. - return widget.builder!(context, child); - }, - ) - : child ?? const SizedBox.shrink(), - ), + child: childWidget, ), ); } diff --git a/packages/flutter/lib/src/material/popup_menu.dart b/packages/flutter/lib/src/material/popup_menu.dart index 364ef591fb..e891352ac6 100644 --- a/packages/flutter/lib/src/material/popup_menu.dart +++ b/packages/flutter/lib/src/material/popup_menu.dart @@ -816,6 +816,7 @@ class _PopupMenuRoute extends PopupRoute { this.constraints, required this.clipBehavior, super.settings, + this.popUpAnimationStyle, }) : itemSizes = List.filled(items.length, null), // Menus always cycle focus through their items irrespective of the // focus traversal edge behavior set in the Navigator. @@ -834,18 +835,22 @@ class _PopupMenuRoute extends PopupRoute { final CapturedThemes capturedThemes; final BoxConstraints? constraints; final Clip clipBehavior; + final AnimationStyle? popUpAnimationStyle; @override Animation createAnimation() { - return CurvedAnimation( - parent: super.createAnimation(), - curve: Curves.linear, - reverseCurve: const Interval(0.0, _kMenuCloseIntervalEnd), - ); + if (popUpAnimationStyle != AnimationStyle.noAnimation) { + return CurvedAnimation( + parent: super.createAnimation(), + curve: popUpAnimationStyle?.curve ?? Curves.linear, + reverseCurve: popUpAnimationStyle?.reverseCurve ?? const Interval(0.0, _kMenuCloseIntervalEnd), + ); + } + return super.createAnimation(); } @override - Duration get transitionDuration => _kMenuDuration; + Duration get transitionDuration => popUpAnimationStyle?.duration ?? _kMenuDuration; @override bool get barrierDismissible => true; @@ -977,6 +982,7 @@ Future showMenu({ BoxConstraints? constraints, Clip clipBehavior = Clip.none, RouteSettings? routeSettings, + AnimationStyle? popUpAnimationStyle, }) { assert(items.isNotEmpty); assert(debugCheckHasMaterialLocalizations(context)); @@ -1008,6 +1014,7 @@ Future showMenu({ constraints: constraints, clipBehavior: clipBehavior, settings: routeSettings, + popUpAnimationStyle: popUpAnimationStyle, )); } @@ -1095,6 +1102,13 @@ typedef PopupMenuItemBuilder = List> Function(BuildContext /// ** See code in examples/api/lib/material/popup_menu/popup_menu.1.dart ** /// {@end-tool} /// +/// {@tool dartpad} +/// This sample showcases how to override the [PopupMenuButton] animation +/// curves and duration using [AnimationStyle]. +/// +/// ** See code in examples/api/lib/material/popup_menu/popup_menu.2.dart ** +/// {@end-tool} +/// /// See also: /// /// * [PopupMenuItem], a popup menu entry for a single value. @@ -1129,6 +1143,7 @@ class PopupMenuButton extends StatefulWidget { this.position, this.clipBehavior = Clip.none, this.useRootNavigator = false, + this.popUpAnimationStyle, }) : assert( !(child != null && icon != null), 'You can only pass [child] or [icon], not both.', @@ -1299,6 +1314,24 @@ class PopupMenuButton extends StatefulWidget { /// Defaults to false. final bool useRootNavigator; + /// Used to override the default animation curves and durations of the popup + /// menu's open and close transitions. + /// + /// If [AnimationStyle.curve] is provided, it will be used to override + /// the default popup animation curve. Otherwise, defaults to [Curves.linear]. + /// + /// If [AnimationStyle.reverseCurve] is provided, it will be used to + /// override the default popup animation reverse curve. Otherwise, defaults to + /// `Interval(0.0, 2.0 / 3.0)`. + /// + /// If [AnimationStyle.duration] is provided, it will be used to override + /// the default popup animation duration. Otherwise, defaults to 300ms. + /// + /// To disable the theme animation, use [AnimationStyle.noAnimation]. + /// + /// If this is null, then the default animation will be used. + final AnimationStyle? popUpAnimationStyle; + @override PopupMenuButtonState createState() => PopupMenuButtonState(); } @@ -1356,6 +1389,7 @@ class PopupMenuButtonState extends State> { constraints: widget.constraints, clipBehavior: widget.clipBehavior, useRootNavigator: widget.useRootNavigator, + popUpAnimationStyle: widget.popUpAnimationStyle, ) .then((T? newValue) { if (!mounted) { diff --git a/packages/flutter/test/animation/animation_style_test.dart b/packages/flutter/test/animation/animation_style_test.dart new file mode 100644 index 0000000000..9221339b3d --- /dev/null +++ b/packages/flutter/test/animation/animation_style_test.dart @@ -0,0 +1,93 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/animation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; + +void main() { + test('copyWith, ==, hashCode basics', () { + expect(AnimationStyle(), AnimationStyle().copyWith()); + expect(AnimationStyle().hashCode, AnimationStyle().copyWith().hashCode); + }); + + testWidgetsWithLeakTracking('AnimationStyle.copyWith() overrides all properties', (WidgetTester tester) async { + final AnimationStyle original = AnimationStyle( + curve: Curves.ease, + duration: const Duration(seconds: 1), + reverseCurve: Curves.ease, + reverseDuration: const Duration(seconds: 1), + ); + final AnimationStyle copy = original.copyWith( + curve: Curves.linear, + duration: const Duration(seconds: 2), + reverseCurve: Curves.linear, + reverseDuration: const Duration(seconds: 2), + ); + expect(copy.curve, Curves.linear); + expect(copy.duration, const Duration(seconds: 2)); + expect(copy.reverseCurve, Curves.linear); + expect(copy.reverseDuration, const Duration(seconds: 2)); + }); + + test('AnimationStyle.lerp identical a,b', () { + expect(AnimationStyle.lerp(null, null, 0), null); + final AnimationStyle data = AnimationStyle(); + expect(identical(AnimationStyle.lerp(data, data, 0.5), data), true); + }); + + testWidgetsWithLeakTracking('default AnimationStyle debugFillProperties', (WidgetTester tester) async { + final AnimationStyle a = AnimationStyle( + curve: Curves.ease, + duration: const Duration(seconds: 1), + reverseCurve: Curves.ease, + reverseDuration: const Duration(seconds: 1), + ); + final AnimationStyle b = AnimationStyle( + curve: Curves.linear, + duration: const Duration(seconds: 2), + reverseCurve: Curves.linear, + reverseDuration: const Duration(seconds: 2), + ); + + expect(AnimationStyle.lerp(a, b, 0), a); + expect(AnimationStyle.lerp(a, b, 0.5), b); + expect(AnimationStyle.lerp(a, b, 1.0), b); + }); + + testWidgetsWithLeakTracking('default AnimationStyle debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + + AnimationStyle().debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()).toList(); + + expect(description, []); + }); + + testWidgetsWithLeakTracking('AnimationStyle implements debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + + AnimationStyle( + curve: Curves.easeInOut, + duration: const Duration(seconds: 1), + reverseCurve: Curves.bounceInOut, + reverseDuration: const Duration(seconds: 2), + ).debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()).toList(); + + expect(description, [ + 'curve: Cubic(0.42, 0.00, 0.58, 1.00)', + 'duration: 0:00:01.000000', + 'reverseCurve: _BounceInOutCurve', + 'reverseDuration: 0:00:02.000000' + ]); + }); +} diff --git a/packages/flutter/test/material/app_test.dart b/packages/flutter/test/material/app_test.dart index e676c30115..56b703c504 100644 --- a/packages/flutter/test/material/app_test.dart +++ b/packages/flutter/test/material/app_test.dart @@ -1525,6 +1525,83 @@ void main() { defaultBehavior.buildScrollbar(capturedContext, child, details); } }, variant: TargetPlatformVariant.all()); + + testWidgetsWithLeakTracking('Override theme animation using AnimationStyle', (WidgetTester tester) async { + final ThemeData lightTheme = ThemeData.light(); + final ThemeData darkTheme = ThemeData.dark(); + + Widget buildWidget({ ThemeMode themeMode = ThemeMode.light, AnimationStyle? animationStyle }) { + return MaterialApp( + theme: lightTheme, + darkTheme: darkTheme, + themeMode: themeMode, + themeAnimationStyle: animationStyle, + home: const Scaffold(body: Text('body')), + ); + } + + // Test the initial Scaffold background color. + await tester.pumpWidget(buildWidget()); + + expect(tester.widget(find.byType(Material)).color, const Color(0xfffffbfe)); + + // Test the Scaffold background color animation from light to dark theme. + await tester.pumpWidget(buildWidget(themeMode: ThemeMode.dark)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); // Advance animation by 50 milliseconds. + + // Scaffold background color is slightly updated. + expect(tester.widget(find.byType(Material)).color, const Color(0xffc6c3c6)); + + // Let the animation finish. + await tester.pumpAndSettle(); + + // Scaffold background color is fully updated to dark theme. + expect(tester.widget(find.byType(Material)).color, const Color(0xff1c1b1f)); + + // Reset to light theme to compare the Scaffold background color animation + // with the default animation curve. + await tester.pumpWidget(buildWidget()); + await tester.pumpAndSettle(); + + // Switch to dark theme with overriden animation curve. + await tester.pumpWidget(buildWidget( + themeMode: ThemeMode.dark, + animationStyle: AnimationStyle(curve: Curves.easeIn, + ))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // Scaffold background color is slightly updated but with a different + // color than the default animation curve. + expect(tester.widget(find.byType(Material)).color, const Color(0xffe9e5e9)); + + // Let the animation finish. + await tester.pumpAndSettle(); + + // Scaffold background color is fully updated to dark theme. + expect(tester.widget(find.byType(Material)).color, const Color(0xff1c1b1f)); + + // Switch from dark to light theme with overriden animation duration. + await tester.pumpWidget(buildWidget(animationStyle: AnimationStyle.noAnimation)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + + expect(tester.widget(find.byType(Material)).color, isNot(const Color(0xff1c1b1f))); + expect(tester.widget(find.byType(Material)).color, const Color(0xfffffbfe)); + }); + + testWidgetsWithLeakTracking('AnimationStyle.noAnimation removes AnimatedTheme from the tree', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp(themeAnimationStyle: AnimationStyle())); + + expect(find.byType(AnimatedTheme), findsOneWidget); + expect(find.byType(Theme), findsOneWidget); + + await tester.pumpWidget(MaterialApp(themeAnimationStyle: AnimationStyle.noAnimation)); + + expect(find.byType(AnimatedTheme), findsNothing); + expect(find.byType(Theme), findsOneWidget); + }); } class MockScrollBehavior extends ScrollBehavior { diff --git a/packages/flutter/test/material/popup_menu_test.dart b/packages/flutter/test/material/popup_menu_test.dart index fed0ae19cf..16bc3d894c 100644 --- a/packages/flutter/test/material/popup_menu_test.dart +++ b/packages/flutter/test/material/popup_menu_test.dart @@ -3872,6 +3872,90 @@ void main() { expect(rootObserver.menuCount, 0); expect(nestedObserver.menuCount, 1); }); + + testWidgetsWithLeakTracking('Override Popup Menu animation using AnimationStyle', (WidgetTester tester) async { + final Key targetKey = UniqueKey(); + + Widget buildPopupMenu({ AnimationStyle? animationStyle }) { + return MaterialApp( + home: Material( + child: Center( + child: PopupMenuButton( + key: targetKey, + popUpAnimationStyle: animationStyle, + itemBuilder: (BuildContext context) { + return >[ + const PopupMenuItem( + value: 1, + child: Text('One'), + ), + const PopupMenuItem( + value: 2, + child: Text('Two'), + ), + const PopupMenuItem( + value: 3, + child: Text('Three'), + ), + ]; + }, + ), + ), + ), + ); + } + + // Test default animation. + await tester.pumpWidget(buildPopupMenu()); + + await tester.tap(find.byKey(targetKey)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); // Advance the animation by 1/3 of its duration. + + expect(tester.getSize(find.byType(Material).last), within(distance: 0.1, from: const Size(112.0, 80.0))); + + await tester.pump(const Duration(milliseconds: 100)); // Advance the animation by 2/3 of its duration. + + expect(tester.getSize(find.byType(Material).last), within(distance: 0.1, from: const Size(112.0, 160.0))); + + await tester.pumpAndSettle(); // Advance the animation to the end. + + expect(tester.getSize(find.byType(Material).last), within(distance: 0.1, from: const Size(112.0, 160.0))); + + // Tap outside to dismiss the menu. + await tester.tapAt(const Offset(20.0, 20.0)); + await tester.pumpAndSettle(); + + // Override the animation duration. + await tester.pumpWidget(buildPopupMenu(animationStyle: AnimationStyle(duration: Duration.zero))); + + await tester.tap(find.byKey(targetKey)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); // Advance the animation by 1 millisecond. + + expect(tester.getSize(find.byType(Material).last), within(distance: 0.1, from: const Size(112.0, 160.0))); + + // Tap outside to dismiss the menu. + await tester.tapAt(const Offset(20.0, 20.0)); + await tester.pumpAndSettle(); + + // Override the animation curve. + await tester.pumpWidget(buildPopupMenu(animationStyle: AnimationStyle(curve: Easing.emphasizedAccelerate))); + + await tester.tap(find.byKey(targetKey)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); // Advance the animation by 1/3 of its duration. + + expect(tester.getSize(find.byType(Material).last), within(distance: 0.1, from: const Size(32.4, 15.4))); + + await tester.pump(const Duration(milliseconds: 100)); // Advance the animation by 2/3 of its duration. + + expect(tester.getSize(find.byType(Material).last), within(distance: 0.1, from: const Size(112.0, 72.2))); + + await tester.pumpAndSettle(); // Advance the animation to the end. + + expect(tester.getSize(find.byType(Material).last), within(distance: 0.1, from: const Size(112.0, 160.0))); + }); } class TestApp extends StatelessWidget {