diff --git a/packages/animations/CHANGELOG.md b/packages/animations/CHANGELOG.md index 0779bea39e8a..2dc7bbdf6186 100644 --- a/packages/animations/CHANGELOG.md +++ b/packages/animations/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.2.0 + +* Adds support for custom `closedShadows` and `openShadows` to `OpenContainer`. +* Fixes a layout overflow issue in the `_ExampleSingleTile` within the example app. + ## 2.1.2 * Updates minimum supported SDK version to Flutter 3.35/Dart 3.9. diff --git a/packages/animations/example/lib/container_transition.dart b/packages/animations/example/lib/container_transition.dart index 26e91cccdc58..a1fae1499288 100644 --- a/packages/animations/example/lib/container_transition.dart +++ b/packages/animations/example/lib/container_transition.dart @@ -128,6 +128,8 @@ class _OpenContainerTransformDemoState body: ListView( padding: const EdgeInsets.all(8.0), children: [ + _CustomShadowExampleCard(transitionType: _transitionType), + const SizedBox(height: 16.0), _OpenContainerWrapper( transitionType: _transitionType, closedBuilder: (BuildContext _, VoidCallback openContainer) { @@ -388,7 +390,7 @@ class _ExampleSingleTile extends StatelessWidget { return _InkWellOverlay( openContainer: openContainer, - height: height, + constraints: const BoxConstraints(minHeight: height), child: Row( children: [ Container( @@ -423,21 +425,73 @@ class _ExampleSingleTile extends StatelessWidget { } class _InkWellOverlay extends StatelessWidget { - const _InkWellOverlay({this.openContainer, this.height, this.child}); + const _InkWellOverlay({ + this.openContainer, + this.height, + this.constraints, + this.child, + }); final VoidCallback? openContainer; final double? height; + final BoxConstraints? constraints; final Widget? child; @override Widget build(BuildContext context) { - return SizedBox( + return Container( height: height, + constraints: constraints, child: InkWell(onTap: openContainer, child: child), ); } } +class _CustomShadowExampleCard extends StatelessWidget { + const _CustomShadowExampleCard({required this.transitionType}); + + final ContainerTransitionType transitionType; + + @override + Widget build(BuildContext context) { + return OpenContainer( + transitionType: transitionType, + openBuilder: (BuildContext context, VoidCallback _) { + return const _DetailsPage(); + }, + closedElevation: 0.0, + closedShadows: const [ + BoxShadow( + color: Colors.blue, + blurRadius: 15.0, + offset: Offset(0.0, 5.0), + ), + ], + openShadows: const [ + BoxShadow( + color: Colors.red, + blurRadius: 40.0, + spreadRadius: 10.0, + offset: Offset(0.0, 10.0), + ), + ], + closedBuilder: (BuildContext context, VoidCallback openContainer) { + return _InkWellOverlay( + openContainer: openContainer, + height: 100, + child: const Center( + child: Text( + 'Custom shadows', + textAlign: TextAlign.center, + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ); + }, + ); + } +} + class _DetailsPage extends StatelessWidget { const _DetailsPage({this.includeMarkAsDoneButton = true}); diff --git a/packages/animations/lib/src/open_container.dart b/packages/animations/lib/src/open_container.dart index 4683c3597642..a9d90f7b73e3 100644 --- a/packages/animations/lib/src/open_container.dart +++ b/packages/animations/lib/src/open_container.dart @@ -137,6 +137,8 @@ class OpenContainer extends StatefulWidget { this.useRootNavigator = false, this.routeSettings, this.clipBehavior = Clip.antiAlias, + this.closedShadows, + this.openShadows, }); /// Background color of the container while it is closed. @@ -299,6 +301,16 @@ class OpenContainer extends StatefulWidget { /// * [Material.clipBehavior], which is used to implement this property. final Clip clipBehavior; + /// Custom shadows of the container while it is closed. + /// + /// If this is provided, [closedElevation] will be ignored. + final List? closedShadows; + + /// Custom shadows of the container while it is open. + /// + /// If this is provided, [openElevation] will be ignored. + final List? openShadows; + @override State> createState() => OpenContainerState(); } @@ -349,6 +361,8 @@ class OpenContainerState extends State> { transitionType: widget.transitionType, useRootNavigator: widget.useRootNavigator, routeSettings: widget.routeSettings, + closedShadows: widget.closedShadows, + openShadows: widget.openShadows, ), ); if (widget.onClosed != null) { @@ -358,22 +372,32 @@ class OpenContainerState extends State> { @override Widget build(BuildContext context) { + final Widget material = Material( + clipBehavior: widget.clipBehavior, + color: widget.closedColor, + elevation: widget.closedShadows == null ? widget.closedElevation : 0.0, + shape: widget.closedShape, + child: Builder( + key: _closedBuilderKey, + builder: (BuildContext context) { + return widget.closedBuilder(context, openContainer); + }, + ), + ); + return _Hideable( key: _hideableKey, child: GestureDetector( onTap: widget.tappable ? openContainer : null, - child: Material( - clipBehavior: widget.clipBehavior, - color: widget.closedColor, - elevation: widget.closedElevation, - shape: widget.closedShape, - child: Builder( - key: _closedBuilderKey, - builder: (BuildContext context) { - return widget.closedBuilder(context, openContainer); - }, - ), - ), + child: widget.closedShadows == null + ? material + : DecoratedBox( + decoration: ShapeDecoration( + shape: widget.closedShape, + shadows: widget.closedShadows, + ), + child: material, + ), ), ); } @@ -464,10 +488,13 @@ class _OpenContainerRoute extends ModalRoute { required this.transitionType, required this.useRootNavigator, required RouteSettings? routeSettings, + required this.closedShadows, + required this.openShadows, }) : _elevationTween = Tween( - begin: closedElevation, - end: openElevation, + begin: closedShadows == null ? closedElevation : 0.0, + end: openShadows == null ? openElevation : 0.0, ), + _shadowsTween = _getShadowsTween(closedShadows, openShadows), _shapeTween = ShapeBorderTween(begin: closedShape, end: openShape), _colorTween = _getColorTween( transitionType: transitionType, @@ -580,6 +607,8 @@ class _OpenContainerRoute extends ModalRoute { final ShapeBorder openShape; final CloseContainerBuilder closedBuilder; final OpenContainerBuilder openBuilder; + final List? closedShadows; + final List? openShadows; // See [_OpenContainerState._hideableKey]. final GlobalKey<_HideableState> hideableKey; @@ -594,6 +623,7 @@ class _OpenContainerRoute extends ModalRoute { final bool useRootNavigator; final Tween _elevationTween; + final Animatable?> _shadowsTween; final ShapeBorderTween _shapeTween; final _FlippableTweenSequence _closedOpacityTween; final _FlippableTweenSequence _openOpacityTween; @@ -625,6 +655,16 @@ class _OpenContainerRoute extends ModalRoute { // the bounds of the enclosing [Navigator]. final RectTween _rectTween = RectTween(); + static Animatable?> _getShadowsTween( + List? begin, + List? end, + ) { + if (begin == null && end == null) { + return ConstantTween?>(null); + } + return _ShadowsTween(begin: begin, end: end); + } + AnimationStatus? _lastAnimationStatus; AnimationStatus? _currentAnimationStatus; @@ -769,19 +809,29 @@ class _OpenContainerRoute extends ModalRoute { animation: animation, builder: (BuildContext context, Widget? child) { if (animation.isCompleted) { - return SizedBox.expand( - child: Material( - color: openColor, - elevation: openElevation, - shape: openShape, - child: Builder( - key: _openBuilderKey, - builder: (BuildContext context) { - return openBuilder(context, closeContainer); - }, - ), + final Widget material = Material( + color: openColor, + elevation: openShadows == null ? openElevation : 0.0, + shape: openShape, + child: Builder( + key: _openBuilderKey, + builder: (BuildContext context) { + return openBuilder(context, closeContainer); + }, ), ); + + return SizedBox.expand( + child: openShadows == null + ? material + : DecoratedBox( + decoration: ShapeDecoration( + shape: openShape, + shadows: openShadows, + ), + child: material, + ), + ); } final Animation curvedAnimation = CurvedAnimation( @@ -822,6 +872,64 @@ class _OpenContainerRoute extends ModalRoute { assert(scrimTween != null); final Rect rect = _rectTween.evaluate(curvedAnimation)!; + final Widget material = Material( + clipBehavior: Clip.antiAlias, + animationDuration: Duration.zero, + color: colorTween!.evaluate(animation), + shape: _shapeTween.evaluate(curvedAnimation), + elevation: _elevationTween.evaluate(curvedAnimation), + child: Stack( + fit: StackFit.passthrough, + children: [ + // Closed child fading out. + FittedBox( + fit: BoxFit.fitWidth, + alignment: Alignment.topLeft, + child: SizedBox( + width: _rectTween.begin!.width, + height: _rectTween.begin!.height, + child: (hideableKey.currentState?.isInTree ?? false) + ? null + : FadeTransition( + opacity: closedOpacityTween!.animate(animation), + child: Builder( + key: closedBuilderKey, + builder: (BuildContext context) { + // Use dummy "open container" callback + // since we are in the process of opening. + return closedBuilder(context, () {}); + }, + ), + ), + ), + ), + + // Open child fading in. + FittedBox( + fit: BoxFit.fitWidth, + alignment: Alignment.topLeft, + child: SizedBox( + width: _rectTween.end!.width, + height: _rectTween.end!.height, + child: FadeTransition( + opacity: openOpacityTween!.animate(animation), + child: Builder( + key: _openBuilderKey, + builder: (BuildContext context) { + return openBuilder(context, closeContainer); + }, + ), + ), + ), + ), + ], + ), + ); + + final List? currentShadows = _shadowsTween.evaluate( + curvedAnimation, + ); + return SizedBox.expand( child: Container( color: scrimTween!.evaluate(curvedAnimation), @@ -832,62 +940,15 @@ class _OpenContainerRoute extends ModalRoute { child: SizedBox( width: rect.width, height: rect.height, - child: Material( - clipBehavior: Clip.antiAlias, - animationDuration: Duration.zero, - color: colorTween!.evaluate(animation), - shape: _shapeTween.evaluate(curvedAnimation), - elevation: _elevationTween.evaluate(curvedAnimation), - child: Stack( - fit: StackFit.passthrough, - children: [ - // Closed child fading out. - FittedBox( - fit: BoxFit.fitWidth, - alignment: Alignment.topLeft, - child: SizedBox( - width: _rectTween.begin!.width, - height: _rectTween.begin!.height, - child: - (hideableKey.currentState?.isInTree ?? false) - ? null - : FadeTransition( - opacity: closedOpacityTween!.animate( - animation, - ), - child: Builder( - key: closedBuilderKey, - builder: (BuildContext context) { - // Use dummy "open container" callback - // since we are in the process of opening. - return closedBuilder(context, () {}); - }, - ), - ), - ), - ), - - // Open child fading in. - FittedBox( - fit: BoxFit.fitWidth, - alignment: Alignment.topLeft, - child: SizedBox( - width: _rectTween.end!.width, - height: _rectTween.end!.height, - child: FadeTransition( - opacity: openOpacityTween!.animate(animation), - child: Builder( - key: _openBuilderKey, - builder: (BuildContext context) { - return openBuilder(context, closeContainer); - }, - ), - ), + child: currentShadows == null + ? material + : DecoratedBox( + decoration: ShapeDecoration( + shape: _shapeTween.evaluate(curvedAnimation)!, + shadows: currentShadows, ), + child: material, ), - ], - ), - ), ), ), ), @@ -936,3 +997,12 @@ class _FlippableTweenSequence extends TweenSequence { return _flipped; } } + +class _ShadowsTween extends Tween?> { + _ShadowsTween({super.begin, super.end}); + + @override + List? lerp(double t) { + return BoxShadow.lerpList(begin, end, t); + } +} diff --git a/packages/animations/pubspec.yaml b/packages/animations/pubspec.yaml index 0670649c6e95..5f95401cb958 100644 --- a/packages/animations/pubspec.yaml +++ b/packages/animations/pubspec.yaml @@ -2,7 +2,7 @@ name: animations description: Fancy pre-built animations that can easily be integrated into any Flutter application. repository: https://github.com/flutter/packages/tree/main/packages/animations issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+animations%22 -version: 2.1.2 +version: 2.2.0 environment: sdk: ^3.9.0 diff --git a/packages/animations/test/open_container_test.dart b/packages/animations/test/open_container_test.dart index 673547039992..42af1224fc54 100644 --- a/packages/animations/test/open_container_test.dart +++ b/packages/animations/test/open_container_test.dart @@ -355,6 +355,106 @@ void main() { expect(dataClosed.rect, dataTransitionDone.rect); }); + testWidgets('Custom shadows work', (WidgetTester tester) async { + const closedShadows = [ + BoxShadow(color: Colors.blue, blurRadius: 10.0), + ]; + const openShadows = [ + BoxShadow(color: Colors.red, blurRadius: 20.0), + ]; + + await tester.pumpWidget( + _boilerplate( + child: Center( + child: OpenContainer( + closedShadows: closedShadows, + openShadows: openShadows, + closedBuilder: (BuildContext context, VoidCallback _) { + return const Text('Closed'); + }, + openBuilder: (BuildContext context, VoidCallback _) { + return const Text('Open'); + }, + ), + ), + ), + ); + + // Verify closed state: Material elevation should be 0 because custom shadows are provided. + final Element srcMaterialElement = tester.firstElement( + find.ancestor(of: find.text('Closed'), matching: find.byType(Material)), + ); + final srcMaterial = srcMaterialElement.widget as Material; + expect(srcMaterial.elevation, 0.0); + + // Verify DecoratedBox has the correct shadows. + final Element decoratedBoxElement = tester.firstElement( + find.ancestor( + of: find.byElementPredicate((Element e) => e == srcMaterialElement), + matching: find.byType(DecoratedBox), + ), + ); + final decoration = + (decoratedBoxElement.widget as DecoratedBox).decoration + as ShapeDecoration; + expect(decoration.shadows, closedShadows); + + // Open the container. + await tester.tap(find.text('Closed')); + await tester.pump(); // Start animation. + await tester.pump(const Duration(milliseconds: 150)); // Mid-point. + + final Element transitioningMaterialElement = tester.firstElement( + find.ancestor(of: find.text('Closed'), matching: find.byType(Material)), + ); + final transitioningMaterial = + transitioningMaterialElement.widget as Material; + expect(transitioningMaterial.elevation, 0.0); + + final Element transitioningDecoratedBoxElement = tester.firstElement( + find.ancestor( + of: find.byElementPredicate( + (Element e) => e == transitioningMaterialElement, + ), + matching: find.byType(DecoratedBox), + ), + ); + final transitioningDecoration = + (transitioningDecoratedBoxElement.widget as DecoratedBox).decoration + as ShapeDecoration; + + // Verify shadows are lerping. + final double expectedT = Curves.fastOutSlowIn.transform(0.5); + expect( + transitioningDecoration.shadows![0].color, + Color.lerp(Colors.blue, Colors.red, expectedT), + ); + expect( + transitioningDecoration.shadows![0].blurRadius, + 10.0 + (20.0 - 10.0) * expectedT, + ); + + await tester.pumpAndSettle(); + + // Verify open state. + final Element openMaterialElement = tester.firstElement( + find.ancestor(of: find.text('Open'), matching: find.byType(Material)), + ); + final openMaterial = openMaterialElement.widget as Material; + expect(openMaterial.elevation, 0.0); + + final Element openDecoratedBoxElement = tester.firstElement( + find.ancestor( + of: find.byElementPredicate((Element e) => e == openMaterialElement), + matching: find.byType(DecoratedBox), + ), + ); + final openDecoration = + (openDecoratedBoxElement.widget as DecoratedBox).decoration + as ShapeDecoration; + expect(openDecoration.shadows, openShadows); + }); + testWidgets('Container opens - Fade through', (WidgetTester tester) async { const ShapeBorder shape = RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(8.0)),