From 0b809df740574c59a893462fc59eff620b86a5e8 Mon Sep 17 00:00:00 2001 From: Michael Goderbauer Date: Tue, 26 Nov 2019 09:54:09 -0800 Subject: [PATCH] Adds animations package with open container transition (#30) --- packages/animations/.gitignore | 73 ++ packages/animations/.metadata | 10 + packages/animations/CHANGELOG.md | 3 + packages/animations/LICENSE | 27 + packages/animations/README.md | 15 + packages/animations/lib/animations.dart | 5 + .../animations/lib/src/open_container.dart | 691 +++++++++++++ packages/animations/pubspec.yaml | 15 + .../animations/test/open_container_test.dart | 934 ++++++++++++++++++ 9 files changed, 1773 insertions(+) create mode 100644 packages/animations/.gitignore create mode 100644 packages/animations/.metadata create mode 100644 packages/animations/CHANGELOG.md create mode 100644 packages/animations/LICENSE create mode 100644 packages/animations/README.md create mode 100644 packages/animations/lib/animations.dart create mode 100644 packages/animations/lib/src/open_container.dart create mode 100644 packages/animations/pubspec.yaml create mode 100644 packages/animations/test/open_container_test.dart diff --git a/packages/animations/.gitignore b/packages/animations/.gitignore new file mode 100644 index 0000000000..3132dc5ff7 --- /dev/null +++ b/packages/animations/.gitignore @@ -0,0 +1,73 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages diff --git a/packages/animations/.metadata b/packages/animations/.metadata new file mode 100644 index 0000000000..28ddf847a7 --- /dev/null +++ b/packages/animations/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: ce3c467292293176452a3b4b4fee70073f5af9b3 + channel: master + +project_type: package diff --git a/packages/animations/CHANGELOG.md b/packages/animations/CHANGELOG.md new file mode 100644 index 0000000000..c84cb801c6 --- /dev/null +++ b/packages/animations/CHANGELOG.md @@ -0,0 +1,3 @@ +## [0.0.1] - August 28, 2019 + +* Open Container to full screen transition diff --git a/packages/animations/LICENSE b/packages/animations/LICENSE new file mode 100644 index 0000000000..73e6b6ec67 --- /dev/null +++ b/packages/animations/LICENSE @@ -0,0 +1,27 @@ +Copyright 2019 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/animations/README.md b/packages/animations/README.md new file mode 100644 index 0000000000..9195dc44d3 --- /dev/null +++ b/packages/animations/README.md @@ -0,0 +1,15 @@ +# Fancy pre-built Animations for Flutter + +**This package is still under heavy development and may change frequently.** + +This package contains pre-canned animations for commonly-desired effects. The animations can be customized with your content and dropped into your application to delight your users. + +## Available Animations + +Currently, the following animated effects are available in this library: + +### Material's [Open Container Transitions](https://material.io/design/motion/choreography.html#transformation) + +Tapping on a container (e.g. a card or a button) will expand the container to reveal more information. + +*TODO(goderbauer): Add example videos of this effect.* diff --git a/packages/animations/lib/animations.dart b/packages/animations/lib/animations.dart new file mode 100644 index 0000000000..f58279ddaa --- /dev/null +++ b/packages/animations/lib/animations.dart @@ -0,0 +1,5 @@ +// Copyright 2019 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. + +export 'src/open_container.dart'; diff --git a/packages/animations/lib/src/open_container.dart b/packages/animations/lib/src/open_container.dart new file mode 100644 index 0000000000..0a65ec5683 --- /dev/null +++ b/packages/animations/lib/src/open_container.dart @@ -0,0 +1,691 @@ +// Copyright 2019 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/scheduler.dart'; + +/// Signature for a function that creates a [Widget] to be used within an +/// [OpenContainer]. +/// +/// The `action` callback provided to [OpenContainer.openBuilder] can be used +/// to open the container. The `action` callback provided to +/// [OpenContainer.closedBuilder] can be used to close the container again. +typedef OpenContainerBuilder = Widget Function( + BuildContext context, + VoidCallback action, +); + +/// A container that grows to fill the screen to reveal new content when tapped. +/// +/// While the container is closed, it shows the [Widget] returned by +/// [closedBuilder]. When the container is tapped it grows to fill the entire +/// size of the surrounding [Navigator] while fading out the widget returned by +/// [closedBuilder] and fading in the widget returned by [openBuilder]. When the +/// container is closed again via the callback provided to [openBuilder] or via +/// Android's back button, the animation is reversed: The container shrinks back +/// to its original size while the widget returned by [openBuilder] is faded out +/// and the widget returned by [openBuilder] is faded back in. +/// +/// By default, the container is in the closed state. During the transition from +/// closed to open and vice versa the widgets returned by the [openBuilder] and +/// [closedBuilder] exist in the tree at the same time. Therefore, the widgets +/// returned by these builders cannot include the same global key. +/// +// TODO(goderbauer): Add example animations and sample code. +/// +/// See also: +/// +/// * [Transitions with animated containers](https://material.io/design/motion/choreography.html#transformation) +/// in the Material spec. +class OpenContainer extends StatefulWidget { + /// Creates an [OpenContainer]. + /// + /// All arguments except for [key] must not be null. The arguments + /// [closedBuilder] and [closedBuilder] are required. + const OpenContainer({ + Key key, + this.closedColor = Colors.white, + this.openColor = Colors.white, + this.closedElevation = 1.0, + this.openElevation = 4.0, + this.closedShape = const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4.0)), + ), + this.openShape = const RoundedRectangleBorder(), + @required this.closedBuilder, + @required this.openBuilder, + this.tappable = true, + this.transitionDuration = const Duration(milliseconds: 300), + }) : assert(closedColor != null), + assert(openColor != null), + assert(closedElevation != null), + assert(openElevation != null), + assert(closedShape != null), + assert(openShape != null), + assert(closedBuilder != null), + assert(openBuilder != null), + assert(tappable != null), + super(key: key); + + /// Background color of the container while it is closed. + /// + /// When the container is opened, it will first transition from this color + /// to [Colors.white] and then transition from there to [openColor] in one + /// smooth animation. When the container is closed, it will transition back to + /// this color from [openColor] via [Colors.white]. + /// + /// Defaults to [Colors.white]. + /// + /// See also: + /// + /// * [Material.color], which is used to implement this property. + final Color closedColor; + + /// Background color of the container while it is open. + /// + /// When the container is closed, it will first transition from [closedColor] + /// to [Colors.white] and then transition from there to this color in one + /// smooth animation. When the container is closed, it will transition back to + /// [closedColor] from this color via [Colors.white]. + /// + /// Defaults to [Colors.white]. + /// + /// See also: + /// + /// * [Material.color], which is used to implement this property. + final Color openColor; + + /// Elevation of the container while it is closed. + /// + /// When the container is opened, it will transition from this elevation to + /// [openElevation]. When the container is closed, it will transition back + /// from [openElevation] to this elevation. + /// + /// Defaults to 1.0. + /// + /// See also: + /// + /// * [Material.elevation], which is used to implement this property. + final double closedElevation; + + /// Elevation of the container while it is open. + /// + /// When the container is opened, it will transition to this elevation from + /// [closedElevation]. When the container is closed, it will transition back + /// from this elevation to [closedElevation]. + /// + /// Defaults to 4.0. + /// + /// See also: + /// + /// * [Material.elevation], which is used to implement this property. + final double openElevation; + + /// Shape of the container while it is closed. + /// + /// When the container is opened it will transition from this shape to + /// [openShape]. When the container is closed, it will transition back to this + /// shape. + /// + /// Defaults to a [RoundedRectangleBorder] with a [Radius.circular] of 4.0. + /// + /// See also: + /// + /// * [Material.shape], which is used to implement this property. + final ShapeBorder closedShape; + + /// Shape of the container while it is open. + /// + /// When the container is opened it will transition from [closedShape] to + /// this shape. When the container is closed, it will transition from this + /// shape back to [closedShape]. + /// + /// Defaults to a rectangular. + /// + /// See also: + /// + /// * [Material.shape], which is used to implement this property. + final ShapeBorder openShape; + + /// Called to obtain the child for the container in the closed state. + /// + /// The [Widget] returned by this builder is faded out when the container + /// opens and at the same time the widget returned by [openBuilder] is faded + /// in while the container grows to fill the surrounding [Navigator]. + /// + /// The `action` callback provided to the builder can be called to open the + /// container. + final OpenContainerBuilder closedBuilder; + + /// Called to obtain the child for the container in the open state. + /// + /// The [Widget] returned by this builder is faded in when the container + /// opens and at the same time the widget returned by [closedBuilder] is + /// faded out while the container grows to fill the surrounding [Navigator]. + /// + /// The `action` callback provided to the builder can be called to close the + /// container. + final OpenContainerBuilder openBuilder; + + /// Whether the entire closed container can be tapped to open it. + /// + /// Defaults to true. + /// + /// When this is set to false the container can only be opened by calling the + /// `action` callback that is provided to the [closedBuilder]. + final bool tappable; + + /// The time it will take to animate the container from its closed to its + /// open state and vice versa. + /// + /// Defaults to 300ms. + final Duration transitionDuration; + + @override + _OpenContainerState createState() => _OpenContainerState(); +} + +class _OpenContainerState extends State { + // Key used in [_OpenContainerRoute] to hide the widget returned by + // [OpenContainer.openBuilder] in the source route while the container is + // opening/open. A copy of that widget is included in the + // [_OpenContainerRoute] where it fades out. To avoid issues with double + // shadows and transparency, we hide it in the source route. + final GlobalKey<_HideableState> _hideableKey = GlobalKey<_HideableState>(); + + // Key used to steal the state of the widget returned by + // [OpenContainer.openBuilder] from the source route and attach it to the + // same widget included in the [_OpenContainerRoute] where it fades out. + final GlobalKey _closedBuilderKey = GlobalKey(); + + void openContainer() { + Navigator.of(context).push(_OpenContainerRoute( + closedColor: widget.closedColor, + openColor: widget.openColor, + closedElevation: widget.closedElevation, + openElevation: widget.openElevation, + closedShape: widget.closedShape, + openShape: widget.openShape, + closedBuilder: widget.closedBuilder, + openBuilder: widget.openBuilder, + hideableKey: _hideableKey, + closedBuilderKey: _closedBuilderKey, + transitionDuration: widget.transitionDuration, + )); + } + + @override + Widget build(BuildContext context) { + return _Hideable( + key: _hideableKey, + child: GestureDetector( + onTap: widget.tappable ? openContainer : null, + child: Material( + clipBehavior: Clip.antiAlias, + color: widget.closedColor, + elevation: widget.closedElevation, + shape: widget.closedShape, + child: Builder( + key: _closedBuilderKey, + builder: (BuildContext context) { + return widget.closedBuilder(context, openContainer); + }, + ), + ), + ), + ); + } +} + +/// Controls the visibility of its child. +/// +/// The child can be in one of three states: +/// +/// * It is included in the tree and fully visible. (The `placeholderSize` is +/// null and `isVisible` is true.) +/// * It is included in the tree, but not visible; its size is maintained. +/// (The `placeholderSize` is null and `isVisible` is false.) +/// * It is not included in the tree. Instead a [SizedBox] of dimensions +/// specified by `placeholderSize` is included in the tree. (The value of +/// `isVisible` is ignored). +class _Hideable extends StatefulWidget { + const _Hideable({ + Key key, + this.child, + }) : super(key: key); + + final Widget child; + + @override + State<_Hideable> createState() => _HideableState(); +} + +class _HideableState extends State<_Hideable> { + /// When non-null the child is replaced by a [SizedBox] of the set size. + Size get placeholderSize => _placeholderSize; + Size _placeholderSize; + set placeholderSize(Size value) { + if (_placeholderSize == value) { + return; + } + setState(() { + _placeholderSize = value; + }); + } + + /// When true the child is not visible, but will maintain its size. + /// + /// The value of this property is ignored when [placeholderSize] is non-null + /// (i.e. [isInTree] returns false). + bool get isVisible => _visible; + bool _visible = true; + set isVisible(bool value) { + assert(value != null); + if (_visible == value) { + return; + } + setState(() { + _visible = value; + }); + } + + /// Whether the child is currently included in the tree. + /// + /// When it is included, it may be visible or not according to [isVisible]. + bool get isInTree => _placeholderSize == null; + + @override + Widget build(BuildContext context) { + if (_placeholderSize != null) { + return SizedBox.fromSize(size: _placeholderSize); + } + return Opacity( + opacity: _visible ? 1.0 : 0.0, + child: widget.child, + ); + } +} + +class _OpenContainerRoute extends ModalRoute { + _OpenContainerRoute({ + @required Color closedColor, + @required this.openColor, + @required double closedElevation, + @required this.openElevation, + @required ShapeBorder closedShape, + @required this.openShape, + @required this.closedBuilder, + @required this.openBuilder, + @required this.hideableKey, + @required this.closedBuilderKey, + @required this.transitionDuration, + }) : assert(closedColor != null), + assert(openColor != null), + assert(closedElevation != null), + assert(openElevation != null), + assert(closedShape != null), + assert(openBuilder != null), + assert(closedBuilder != null), + assert(hideableKey != null), + assert(closedBuilderKey != null), + _elevationTween = Tween( + begin: closedElevation, + end: openElevation, + ), + _shapeTween = ShapeBorderTween( + begin: closedShape, + end: openShape, + ), + _colorTween = _FlippableTweenSequence(>[ + TweenSequenceItem( + tween: ColorTween(begin: closedColor, end: Colors.white), + weight: 4 / 12, + ), + TweenSequenceItem( + tween: ColorTween(begin: Colors.white, end: openColor), + weight: 8 / 12, + ), + ]); + + final Color openColor; + final double openElevation; + final ShapeBorder openShape; + final OpenContainerBuilder closedBuilder; + final OpenContainerBuilder openBuilder; + + // See [_OpenContainerState._hideableKey]. + final GlobalKey<_HideableState> hideableKey; + + // See [_OpenContainerState._closedBuilderKey]. + final GlobalKey closedBuilderKey; + + @override + final Duration transitionDuration; + + final Tween _elevationTween; + final ShapeBorderTween _shapeTween; + final _FlippableTweenSequence _colorTween; + + final _FlippableTweenSequence _closedOpacityTween = + _FlippableTweenSequence(>[ + TweenSequenceItem( + tween: Tween(begin: 1.0, end: 0.0), + weight: 4 / 12, + ), + TweenSequenceItem( + tween: ConstantTween(0.0), + weight: 8 / 12, + ), + ]); + final _FlippableTweenSequence _openOpacityTween = + _FlippableTweenSequence(>[ + TweenSequenceItem( + tween: ConstantTween(0.0), + weight: 4 / 12, + ), + TweenSequenceItem( + tween: Tween(begin: 0.0, end: 1.0), + weight: 8 / 12, + ), + ]); + + // Key used for the widget returned by [OpenContainer.openBuilder] to keep + // its state when the shape of the widget tree is changed at the end of the + // animation to remove all the craft that was necessary to make the animation + // work. + final GlobalKey _openBuilderKey = GlobalKey(); + + // Defines the position of the (opening) [OpenContainer] within the bounds of + // the enclosing [Navigator]. + final RectTween _insetsTween = RectTween(end: Rect.zero); + + // Defines the size of the [OpenContainer]. + final SizeTween _sizeTween = SizeTween(); + + AnimationStatus _lastAnimationStatus; + AnimationStatus _currentAnimationStatus; + + @override + TickerFuture didPush() { + _takeMeasurements(navigatorContext: hideableKey.currentContext); + + animation.addStatusListener((AnimationStatus status) { + _lastAnimationStatus = _currentAnimationStatus; + _currentAnimationStatus = status; + switch (status) { + case AnimationStatus.dismissed: + hideableKey.currentState + ..placeholderSize = null + ..isVisible = true; + break; + case AnimationStatus.completed: + hideableKey.currentState + ..placeholderSize = null + ..isVisible = false; + break; + case AnimationStatus.forward: + case AnimationStatus.reverse: + break; + } + }); + + return super.didPush(); + } + + @override + bool didPop(void result) { + _takeMeasurements( + navigatorContext: subtreeContext, + delayForSourceRoute: true, + ); + return super.didPop(result); + } + + void _takeMeasurements({ + BuildContext navigatorContext, + bool delayForSourceRoute = false, + }) { + final RenderBox navigator = + Navigator.of(navigatorContext).context.findRenderObject(); + final Size navSize = _getSize(navigator); + _sizeTween.end = navSize; + + void takeMeasurementsInSourceRoute([Duration _]) { + if (!navigator.attached || hideableKey.currentContext == null) { + return; + } + final Rect srcRect = _getRect(hideableKey, navigator); + _sizeTween.begin = srcRect.size; + _insetsTween.begin = Rect.fromLTRB( + srcRect.left, + srcRect.top, + navSize.width - srcRect.right, + navSize.height - srcRect.bottom, + ); + hideableKey.currentState.placeholderSize = _sizeTween.begin; + } + + if (delayForSourceRoute) { + SchedulerBinding.instance + .addPostFrameCallback(takeMeasurementsInSourceRoute); + } else { + takeMeasurementsInSourceRoute(); + } + } + + Size _getSize(RenderBox render) { + assert(render != null && render.hasSize); + return render.size; + } + + // Returns the bounds of the [RenderObject] identified by `key` in the + // coordinate system of `ancestor`. + Rect _getRect(GlobalKey key, RenderBox ancestor) { + assert(key.currentContext != null); + assert(ancestor != null && ancestor.hasSize); + final RenderBox render = key.currentContext.findRenderObject(); + assert(render != null && render.hasSize); + return MatrixUtils.transformRect( + render.getTransformTo(ancestor), + Offset.zero & render.size, + ); + } + + bool get _transitionWasInterrupted { + bool wasInProgress = false; + bool isInProgress = false; + + switch (_currentAnimationStatus) { + case AnimationStatus.completed: + case AnimationStatus.dismissed: + isInProgress = false; + break; + case AnimationStatus.forward: + case AnimationStatus.reverse: + isInProgress = true; + break; + } + switch (_lastAnimationStatus) { + case AnimationStatus.completed: + case AnimationStatus.dismissed: + wasInProgress = false; + break; + case AnimationStatus.forward: + case AnimationStatus.reverse: + wasInProgress = true; + break; + } + return wasInProgress && isInProgress; + } + + void closeContainer() { + Navigator.of(subtreeContext).pop(); + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return Align( + alignment: Alignment.topLeft, + child: AnimatedBuilder( + 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 Animation curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.fastOutSlowIn, + reverseCurve: + _transitionWasInterrupted ? null : Curves.fastOutSlowIn.flipped, + ); + TweenSequence colorTween; + TweenSequence closedOpacityTween, openOpacityTween; + switch (animation.status) { + case AnimationStatus.dismissed: + case AnimationStatus.forward: + closedOpacityTween = _closedOpacityTween; + openOpacityTween = _openOpacityTween; + colorTween = _colorTween; + break; + case AnimationStatus.reverse: + if (_transitionWasInterrupted) { + closedOpacityTween = _closedOpacityTween; + openOpacityTween = _openOpacityTween; + colorTween = _colorTween; + break; + } + closedOpacityTween = _closedOpacityTween.flipped; + openOpacityTween = _openOpacityTween.flipped; + colorTween = _colorTween.flipped; + break; + case AnimationStatus.completed: + assert(false); // Unreachable. + break; + } + final Rect rect = _insetsTween.evaluate(curvedAnimation); + final Size size = _sizeTween.evaluate(curvedAnimation); + + return Padding( + padding: EdgeInsets.fromLTRB( + rect.left, + rect.top, + rect.right, + rect.bottom, + ), + child: SizedBox( + width: size.width, + height: size.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: _sizeTween.begin.width, + height: _sizeTween.begin.height, + child: hideableKey.currentState.isInTree + ? null + : Opacity( + opacity: closedOpacityTween.evaluate(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: _sizeTween.end.width, + height: _sizeTween.end.height, + child: Opacity( + opacity: openOpacityTween.evaluate(animation), + child: Builder( + key: _openBuilderKey, + builder: (BuildContext context) { + return openBuilder(context, closeContainer); + }, + ), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } + + @override + bool get maintainState => true; + + @override + Color get barrierColor => null; + + @override + bool get opaque => true; + + @override + bool get barrierDismissible => false; + + @override + String get barrierLabel => null; +} + +class _FlippableTweenSequence extends TweenSequence { + _FlippableTweenSequence(this._items) : super(_items); + + final List> _items; + _FlippableTweenSequence _flipped; + + _FlippableTweenSequence get flipped { + if (_flipped == null) { + final List> newItems = >[]; + for (int i = 0; i < _items.length; i++) { + newItems.add(TweenSequenceItem( + tween: _items[i].tween, + weight: _items[_items.length - 1 - i].weight, + )); + } + _flipped = _FlippableTweenSequence(newItems); + } + return _flipped; + } +} diff --git a/packages/animations/pubspec.yaml b/packages/animations/pubspec.yaml new file mode 100644 index 0000000000..4679d9b67b --- /dev/null +++ b/packages/animations/pubspec.yaml @@ -0,0 +1,15 @@ +name: animations +description: Fancy pre-built Animations for Flutter. +version: 0.0.1 +homepage: https://github.com/flutter/packages/tree/master/packages/animations + +environment: + sdk: ">=2.1.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/packages/animations/test/open_container_test.dart b/packages/animations/test/open_container_test.dart new file mode 100644 index 0000000000..947994982e --- /dev/null +++ b/packages/animations/test/open_container_test.dart @@ -0,0 +1,934 @@ +// Copyright 2019 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:animations/src/open_container.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Container opens', (WidgetTester tester) async { + const ShapeBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ); + bool closedBuilderCalled = false; + bool openBuilderCalled = false; + + await tester.pumpWidget(_boilerplate( + child: Center( + child: OpenContainer( + closedColor: Colors.green, + openColor: Colors.blue, + closedElevation: 4.0, + openElevation: 8.0, + closedShape: shape, + closedBuilder: (BuildContext context, VoidCallback _) { + closedBuilderCalled = true; + return const Text('Closed'); + }, + openBuilder: (BuildContext context, VoidCallback _) { + openBuilderCalled = true; + return const Text('Open'); + }, + ), + ), + )); + + // Closed container has the expected properties. + final StatefulElement srcMaterialElement = tester.firstElement( + find.ancestor( + of: find.text('Closed'), + matching: find.byType(Material), + ), + ); + final Material srcMaterial = srcMaterialElement.widget; + expect(srcMaterial.color, Colors.green); + expect(srcMaterial.elevation, 4.0); + expect(srcMaterial.shape, shape); + expect(find.text('Closed'), findsOneWidget); + expect(find.text('Open'), findsNothing); + expect(closedBuilderCalled, isTrue); + expect(openBuilderCalled, isFalse); + final Rect srcMaterialRect = tester.getRect( + find.byElementPredicate((Element e) => e == srcMaterialElement), + ); + + // Open the container. + await tester.tap(find.text('Closed')); + expect(find.text('Closed'), findsOneWidget); + expect(find.text('Open'), findsNothing); + await tester.pump(); + + // On the first frame of the animation everything still looks like before. + final StatefulElement destMaterialElement = tester.firstElement( + find.ancestor( + of: find.text('Closed'), + matching: find.byType(Material), + ), + ); + final Material closedMaterial = destMaterialElement.widget; + expect(closedMaterial.color, Colors.green); + expect(closedMaterial.elevation, 4.0); + expect(closedMaterial.shape, shape); + expect(find.text('Closed'), findsOneWidget); + expect(find.text('Open'), findsOneWidget); + final Rect closedMaterialRect = tester.getRect( + find.byElementPredicate((Element e) => e == destMaterialElement), + ); + expect(closedMaterialRect, srcMaterialRect); + expect(_getOpacity(tester, 'Open'), 0.0); + expect(_getOpacity(tester, 'Closed'), 1.0); + + final _TrackedData dataClosed = _TrackedData( + closedMaterial, + closedMaterialRect, + ); + + // The fade-out takes 4/12 of 300ms. Let's jump to the midpoint of that. + await tester.pump(const Duration(milliseconds: 50)); // 300 * 2/12 = 50 + final _TrackedData dataMidFadeOut = _TrackedData( + destMaterialElement.widget, + tester.getRect( + find.byElementPredicate((Element e) => e == destMaterialElement), + ), + ); + _expectMaterialPropertiesHaveAdvanced( + smallerMaterial: dataClosed, + biggerMaterial: dataMidFadeOut, + tester: tester, + ); + expect(_getOpacity(tester, 'Open'), 0.0); + expect(_getOpacity(tester, 'Closed'), lessThan(1.0)); + expect(_getOpacity(tester, 'Closed'), greaterThan(0.0)); + + // Let's jump to the crossover point at 4/12 of 300ms. + await tester.pump(const Duration(milliseconds: 50)); // 300 * 2/12 = 50 + final _TrackedData dataMidpoint = _TrackedData( + destMaterialElement.widget, + tester.getRect( + find.byElementPredicate((Element e) => e == destMaterialElement), + ), + ); + _expectMaterialPropertiesHaveAdvanced( + smallerMaterial: dataMidFadeOut, + biggerMaterial: dataMidpoint, + tester: tester, + ); + expect(_getOpacity(tester, 'Open'), moreOrLessEquals(0.0)); + expect(_getOpacity(tester, 'Closed'), moreOrLessEquals(0.0)); + + // Let's jump to the middle of the fade-in at 8/12 of 300ms + await tester.pump(const Duration(milliseconds: 100)); // 300 * 4/12 = 100 + final _TrackedData dataMidFadeIn = _TrackedData( + destMaterialElement.widget, + tester.getRect( + find.byElementPredicate((Element e) => e == destMaterialElement), + ), + ); + _expectMaterialPropertiesHaveAdvanced( + smallerMaterial: dataMidpoint, + biggerMaterial: dataMidFadeIn, + tester: tester, + ); + expect(_getOpacity(tester, 'Open'), lessThan(1.0)); + expect(_getOpacity(tester, 'Open'), greaterThan(0.0)); + expect(_getOpacity(tester, 'Closed'), 0.0); + + // Let's jump almost to the end of the transition. + await tester.pump(const Duration(milliseconds: 100)); + final _TrackedData dataTransitionDone = _TrackedData( + destMaterialElement.widget, + tester.getRect( + find.byElementPredicate((Element e) => e == destMaterialElement), + ), + ); + _expectMaterialPropertiesHaveAdvanced( + smallerMaterial: dataMidFadeIn, + biggerMaterial: dataTransitionDone, + tester: tester, + ); + expect(_getOpacity(tester, 'Open'), 1.0); + expect(_getOpacity(tester, 'Closed'), 0.0); + expect(dataTransitionDone.material.color, Colors.blue); + expect(dataTransitionDone.material.elevation, 8.0); + expect(dataTransitionDone.radius, 0.0); + expect(dataTransitionDone.rect, const Rect.fromLTRB(0, 0, 800, 600)); + + await tester.pump(const Duration(milliseconds: 1)); + expect(find.text('Closed'), findsNothing); // No longer in the tree. + expect(find.text('Open'), findsOneWidget); + final StatefulElement finalMaterialElement = tester.firstElement( + find.ancestor( + of: find.text('Open'), + matching: find.byType(Material), + ), + ); + final _TrackedData dataOpen = _TrackedData( + finalMaterialElement.widget, + tester.getRect( + find.byElementPredicate((Element e) => e == finalMaterialElement), + ), + ); + expect(dataOpen.material.color, dataTransitionDone.material.color); + expect(dataOpen.material.elevation, dataTransitionDone.material.elevation); + expect(dataOpen.radius, dataTransitionDone.radius); + expect(dataOpen.rect, dataTransitionDone.rect); + }); + + testWidgets('Container closes', (WidgetTester tester) async { + const ShapeBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ); + + await tester.pumpWidget(_boilerplate( + child: Center( + child: OpenContainer( + closedColor: Colors.green, + openColor: Colors.blue, + closedElevation: 4.0, + openElevation: 8.0, + closedShape: shape, + closedBuilder: (BuildContext context, VoidCallback _) { + return const Text('Closed'); + }, + openBuilder: (BuildContext context, VoidCallback _) { + return const Text('Open'); + }, + ), + ), + )); + + await tester.tap(find.text('Closed')); + await tester.pumpAndSettle(); + + // Open container has the expected properties. + expect(find.text('Closed'), findsNothing); + expect(find.text('Open'), findsOneWidget); + final StatefulElement initialMaterialElement = tester.firstElement( + find.ancestor( + of: find.text('Open'), + matching: find.byType(Material), + ), + ); + final _TrackedData dataOpen = _TrackedData( + initialMaterialElement.widget, + tester.getRect( + find.byElementPredicate((Element e) => e == initialMaterialElement), + ), + ); + expect(dataOpen.material.color, Colors.blue); + expect(dataOpen.material.elevation, 8.0); + expect(dataOpen.radius, 0.0); + expect(dataOpen.rect, const Rect.fromLTRB(0, 0, 800, 600)); + + // Close the container. + final NavigatorState navigator = tester.state(find.byType(Navigator)); + navigator.pop(); + await tester.pump(); + + expect(find.text('Closed'), findsOneWidget); + expect(find.text('Open'), findsOneWidget); + final StatefulElement materialElement = tester.firstElement( + find.ancestor( + of: find.text('Open'), + matching: find.byType(Material), + ), + ); + final _TrackedData dataTransitionStart = _TrackedData( + materialElement.widget, + tester.getRect( + find.byElementPredicate((Element e) => e == materialElement), + ), + ); + expect(dataTransitionStart.material.color, dataOpen.material.color); + expect(dataTransitionStart.material.elevation, dataOpen.material.elevation); + expect(dataTransitionStart.radius, dataOpen.radius); + expect(dataTransitionStart.rect, dataOpen.rect); + expect(_getOpacity(tester, 'Open'), 1.0); + expect(_getOpacity(tester, 'Closed'), 0.0); + + // Jump to mid-point of fade-out: 2/12 of 300. + await tester.pump(const Duration(milliseconds: 50)); // 300 * 2/12 = 50 + final _TrackedData dataMidFadeOut = _TrackedData( + materialElement.widget, + tester.getRect( + find.byElementPredicate((Element e) => e == materialElement), + ), + ); + _expectMaterialPropertiesHaveAdvanced( + smallerMaterial: dataMidFadeOut, + biggerMaterial: dataTransitionStart, + tester: tester, + ); + expect(_getOpacity(tester, 'Closed'), 0.0); + expect(_getOpacity(tester, 'Open'), lessThan(1.0)); + expect(_getOpacity(tester, 'Open'), greaterThan(0.0)); + + // Let's jump to the crossover point at 4/12 of 300ms. + await tester.pump(const Duration(milliseconds: 50)); // 300 * 2/12 = 50 + final _TrackedData dataMidpoint = _TrackedData( + materialElement.widget, + tester.getRect( + find.byElementPredicate((Element e) => e == materialElement), + ), + ); + _expectMaterialPropertiesHaveAdvanced( + smallerMaterial: dataMidpoint, + biggerMaterial: dataMidFadeOut, + tester: tester, + ); + expect(_getOpacity(tester, 'Open'), moreOrLessEquals(0.0)); + expect(_getOpacity(tester, 'Closed'), moreOrLessEquals(0.0)); + + // Let's jump to the middle of the fade-in at 8/12 of 300ms + await tester.pump(const Duration(milliseconds: 100)); // 300 * 4/12 = 100 + final _TrackedData dataMidFadeIn = _TrackedData( + materialElement.widget, + tester.getRect( + find.byElementPredicate((Element e) => e == materialElement), + ), + ); + _expectMaterialPropertiesHaveAdvanced( + smallerMaterial: dataMidFadeIn, + biggerMaterial: dataMidpoint, + tester: tester, + ); + expect(_getOpacity(tester, 'Closed'), lessThan(1.0)); + expect(_getOpacity(tester, 'Closed'), greaterThan(0.0)); + expect(_getOpacity(tester, 'Open'), 0.0); + + // Let's jump almost to the end of the transition. + await tester.pump(const Duration(milliseconds: 100)); + final _TrackedData dataTransitionDone = _TrackedData( + materialElement.widget, + tester.getRect( + find.byElementPredicate((Element e) => e == materialElement), + ), + ); + _expectMaterialPropertiesHaveAdvanced( + smallerMaterial: dataTransitionDone, + biggerMaterial: dataMidFadeIn, + tester: tester, + ); + expect(_getOpacity(tester, 'Closed'), 1.0); + expect(_getOpacity(tester, 'Open'), 0.0); + expect(dataTransitionDone.material.color, Colors.green); + expect(dataTransitionDone.material.elevation, 4.0); + expect(dataTransitionDone.radius, 8.0); + + await tester.pump(const Duration(milliseconds: 1)); + expect(find.text('Open'), findsNothing); // No longer in the tree. + expect(find.text('Closed'), findsOneWidget); + final StatefulElement finalMaterialElement = tester.firstElement( + find.ancestor( + of: find.text('Closed'), + matching: find.byType(Material), + ), + ); + final _TrackedData dataClosed = _TrackedData( + finalMaterialElement.widget, + tester.getRect( + find.byElementPredicate((Element e) => e == finalMaterialElement), + ), + ); + expect(dataClosed.material.color, dataTransitionDone.material.color); + expect( + dataClosed.material.elevation, + dataTransitionDone.material.elevation, + ); + expect(dataClosed.radius, dataTransitionDone.radius); + expect(dataClosed.rect, dataTransitionDone.rect); + }); + + testWidgets('Cannot tap container if tappable=false', + (WidgetTester tester) async { + await tester.pumpWidget(_boilerplate( + child: Center( + child: OpenContainer( + tappable: false, + closedBuilder: (BuildContext context, VoidCallback _) { + return const Text('Closed'); + }, + openBuilder: (BuildContext context, VoidCallback _) { + return const Text('Open'); + }, + ), + ), + )); + + expect(find.text('Open'), findsNothing); + expect(find.text('Closed'), findsOneWidget); + await tester.tap(find.text('Closed')); + await tester.pumpAndSettle(); + expect(find.text('Open'), findsNothing); + expect(find.text('Closed'), findsOneWidget); + }); + + testWidgets('Action callbacks work', (WidgetTester tester) async { + VoidCallback open, close; + await tester.pumpWidget(_boilerplate( + child: Center( + child: OpenContainer( + tappable: false, + closedBuilder: (BuildContext context, VoidCallback action) { + open = action; + return const Text('Closed'); + }, + openBuilder: (BuildContext context, VoidCallback action) { + close = action; + return const Text('Open'); + }, + ), + ), + )); + + expect(find.text('Open'), findsNothing); + expect(find.text('Closed'), findsOneWidget); + + expect(open, isNotNull); + open(); + await tester.pumpAndSettle(); + + expect(find.text('Open'), findsOneWidget); + expect(find.text('Closed'), findsNothing); + + expect(close, isNotNull); + close(); + await tester.pumpAndSettle(); + + expect(find.text('Open'), findsNothing); + expect(find.text('Closed'), findsOneWidget); + }); + + testWidgets('open widget keeps state', (WidgetTester tester) async { + await tester.pumpWidget(_boilerplate( + child: Center( + child: OpenContainer( + closedBuilder: (BuildContext context, VoidCallback action) { + return const Text('Closed'); + }, + openBuilder: (BuildContext context, VoidCallback action) { + return Switch( + value: true, + onChanged: (bool v) {}, + ); + }, + ), + ), + )); + + await tester.tap(find.text('Closed')); + await tester.pump(const Duration(milliseconds: 200)); + + final State stateOpening = tester.state(find.byType(Switch)); + expect(stateOpening, isNotNull); + + await tester.pumpAndSettle(); + expect(find.text('Closed'), findsNothing); + final State stateOpen = tester.state(find.byType(Switch)); + expect(stateOpen, isNotNull); + expect(stateOpen, same(stateOpening)); + + final NavigatorState navigator = tester.state(find.byType(Navigator)); + navigator.pop(); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.text('Closed'), findsOneWidget); + final State stateClosing = tester.state(find.byType(Switch)); + expect(stateClosing, isNotNull); + expect(stateClosing, same(stateOpen)); + }); + + testWidgets('closed widget keeps state', (WidgetTester tester) async { + VoidCallback open; + await tester.pumpWidget(_boilerplate( + child: Center( + child: OpenContainer( + closedBuilder: (BuildContext context, VoidCallback action) { + open = action; + return Switch( + value: true, + onChanged: (bool v) {}, + ); + }, + openBuilder: (BuildContext context, VoidCallback action) { + return const Text('Open'); + }, + ), + ), + )); + + final State stateClosed = tester.state(find.byType(Switch)); + expect(stateClosed, isNotNull); + + open(); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.text('Open'), findsOneWidget); + + final State stateOpening = tester.state(find.byType(Switch)); + expect(stateOpening, same(stateClosed)); + + await tester.pumpAndSettle(); + expect(find.byType(Switch), findsNothing); + expect(find.text('Open'), findsOneWidget); + final State stateOpen = tester.state(find.byType( + Switch, + skipOffstage: false, + )); + expect(stateOpen, same(stateOpening)); + + final NavigatorState navigator = tester.state(find.byType(Navigator)); + navigator.pop(); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.text('Open'), findsOneWidget); + final State stateClosing = tester.state(find.byType(Switch)); + expect(stateClosing, same(stateOpen)); + + await tester.pumpAndSettle(); + expect(find.text('Open'), findsNothing); + final State stateClosedAgain = tester.state(find.byType(Switch)); + expect(stateClosedAgain, same(stateClosing)); + }); + + testWidgets('closes to the right location when src position has changed', + (WidgetTester tester) async { + final Widget openContainer = OpenContainer( + closedBuilder: (BuildContext context, VoidCallback action) { + return Container( + height: 100, + width: 100, + child: const Text('Closed'), + ); + }, + openBuilder: (BuildContext context, VoidCallback action) { + return GestureDetector( + onTap: action, + child: const Text('Open'), + ); + }, + ); + + await tester.pumpWidget(_boilerplate( + child: Align( + alignment: Alignment.topLeft, + child: openContainer, + ), + )); + + final Rect originTextRect = tester.getRect(find.text('Closed')); + expect(originTextRect.topLeft, Offset.zero); + + await tester.tap(find.text('Closed')); + await tester.pumpAndSettle(); + + expect(find.text('Open'), findsOneWidget); + expect(find.text('Closed'), findsNothing); + + await tester.pumpWidget(_boilerplate( + child: Align( + alignment: Alignment.bottomLeft, + child: openContainer, + ), + )); + + expect(find.text('Open'), findsOneWidget); + expect(find.text('Closed'), findsNothing); + + await tester.tap(find.text('Open')); + await tester.pump(); // Need one frame to measure things in the old route. + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Open'), findsOneWidget); + expect(find.text('Closed'), findsOneWidget); + + final Rect transitionEndTextRect = tester.getRect(find.text('Open')); + expect(transitionEndTextRect.topLeft, const Offset(0.0, 600.0 - 100.0)); + + await tester.pump(const Duration(milliseconds: 1)); + expect(find.text('Open'), findsNothing); + expect(find.text('Closed'), findsOneWidget); + + final Rect finalTextRect = tester.getRect(find.text('Closed')); + expect(finalTextRect.topLeft, transitionEndTextRect.topLeft); + }); + + testWidgets('src changes size while open', (WidgetTester tester) async { + double closedSize = 100; + + final Widget openContainer = _boilerplate( + child: Center( + child: OpenContainer( + closedBuilder: (BuildContext context, VoidCallback action) { + return Container( + height: closedSize, + width: closedSize, + child: const Text('Closed'), + ); + }, + openBuilder: (BuildContext context, VoidCallback action) { + return GestureDetector( + onTap: action, + child: const Text('Open'), + ); + }, + ), + ), + ); + + await tester.pumpWidget(openContainer); + + final Size orignalClosedRect = tester.getSize(find + .ancestor( + of: find.text('Closed'), + matching: find.byType(Material), + ) + .first); + expect(orignalClosedRect, const Size(100, 100)); + + await tester.tap(find.text('Closed')); + await tester.pumpAndSettle(); + + expect(find.text('Open'), findsOneWidget); + expect(find.text('Closed'), findsNothing); + + closedSize = 200; + await tester.pumpWidget(openContainer); + + expect(find.text('Open'), findsOneWidget); + expect(find.text('Closed'), findsNothing); + + await tester.tap(find.text('Open')); + await tester.pump(); // Need one frame to measure things in the old route. + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Open'), findsOneWidget); + expect(find.text('Closed'), findsOneWidget); + + final Size transitionEndSize = tester.getSize(find + .ancestor( + of: find.text('Open'), + matching: find.byType(Material), + ) + .first); + expect(transitionEndSize, const Size(200, 200)); + + await tester.pump(const Duration(milliseconds: 1)); + expect(find.text('Open'), findsNothing); + expect(find.text('Closed'), findsOneWidget); + + final Size finalSize = tester.getSize(find + .ancestor( + of: find.text('Closed'), + matching: find.byType(Material), + ) + .first); + expect(finalSize, const Size(200, 200)); + }); + + testWidgets('transition is interrupted and should not jump', + (WidgetTester tester) async { + await tester.pumpWidget(_boilerplate( + child: Center( + child: OpenContainer( + closedBuilder: (BuildContext context, VoidCallback action) { + return const Text('Closed'); + }, + openBuilder: (BuildContext context, VoidCallback action) { + return const Text('Open'); + }, + ), + ), + )); + + await tester.tap(find.text('Closed')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect(find.text('Open'), findsOneWidget); + expect(find.text('Closed'), findsOneWidget); + + final Material openingMaterial = tester.firstWidget(find.ancestor( + of: find.text('Closed'), + matching: find.byType(Material), + )); + final Rect openingRect = tester.getRect( + find.byWidgetPredicate((Widget w) => w == openingMaterial), + ); + + // Close the container while it is half way to open. + final NavigatorState navigator = tester.state(find.byType(Navigator)); + navigator.pop(); + await tester.pump(); + + final Material closingMaterial = tester.firstWidget(find.ancestor( + of: find.text('Open'), + matching: find.byType(Material), + )); + final Rect closingRect = tester.getRect( + find.byWidgetPredicate((Widget w) => w == closingMaterial), + ); + + expect(closingMaterial.elevation, openingMaterial.elevation); + expect(closingMaterial.color, openingMaterial.color); + expect(closingMaterial.shape, openingMaterial.shape); + expect(closingRect, openingRect); + }); + + testWidgets('navigator is not full size', (WidgetTester tester) async { + await tester.pumpWidget(Center( + child: SizedBox( + width: 300, + height: 400, + child: _boilerplate( + child: Center( + child: OpenContainer( + closedBuilder: (BuildContext context, VoidCallback action) { + return const Text('Closed'); + }, + openBuilder: (BuildContext context, VoidCallback action) { + return const Text('Open'); + }, + ), + ), + ), + ), + )); + const Rect fullNavigator = Rect.fromLTWH(250, 100, 300, 400); + + expect(tester.getRect(find.byType(Navigator)), fullNavigator); + final Rect materialRectClosed = tester.getRect(find + .ancestor( + of: find.text('Closed'), + matching: find.byType(Material), + ) + .first); + + await tester.tap(find.text('Closed')); + await tester.pump(); + expect(find.text('Open'), findsOneWidget); + expect(find.text('Closed'), findsOneWidget); + final Rect materialRectTransitionStart = tester.getRect(find.ancestor( + of: find.text('Closed'), + matching: find.byType(Material), + )); + expect(materialRectTransitionStart, materialRectClosed); + + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Open'), findsOneWidget); + expect(find.text('Closed'), findsOneWidget); + final Rect materialRectTransitionEnd = tester.getRect(find.ancestor( + of: find.text('Open'), + matching: find.byType(Material), + )); + expect(materialRectTransitionEnd, fullNavigator); + await tester.pumpAndSettle(); + expect(find.text('Open'), findsOneWidget); + expect(find.text('Closed'), findsNothing); + final Rect materialRectOpen = tester.getRect(find.ancestor( + of: find.text('Open'), + matching: find.byType(Material), + )); + expect(materialRectOpen, fullNavigator); + }); + + testWidgets('does not crash when disposed right after pop', + (WidgetTester tester) async { + await tester.pumpWidget(Center( + child: SizedBox( + width: 300, + height: 400, + child: _boilerplate( + child: Center( + child: OpenContainer( + closedBuilder: (BuildContext context, VoidCallback action) { + return const Text('Closed'); + }, + openBuilder: (BuildContext context, VoidCallback action) { + return const Text('Open'); + }, + ), + ), + ), + ), + )); + await tester.tap(find.text('Closed')); + await tester.pumpAndSettle(); + + final NavigatorState navigator = tester.state(find.byType(Navigator)); + navigator.pop(); + + await tester.pumpWidget(const Placeholder()); + expect(tester.takeException(), isNull); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('can specify a duration', (WidgetTester tester) async { + await tester.pumpWidget(Center( + child: SizedBox( + width: 300, + height: 400, + child: _boilerplate( + child: Center( + child: OpenContainer( + transitionDuration: const Duration(seconds: 2), + closedBuilder: (BuildContext context, VoidCallback action) { + return const Text('Closed'); + }, + openBuilder: (BuildContext context, VoidCallback action) { + return const Text('Open'); + }, + ), + ), + ), + ), + )); + + expect(find.text('Open'), findsNothing); + expect(find.text('Closed'), findsOneWidget); + + await tester.tap(find.text('Closed')); + await tester.pump(); + + // Jump to the end of the transition. + await tester.pump(const Duration(seconds: 2)); + expect(find.text('Open'), findsOneWidget); // faded in + expect(find.text('Closed'), findsOneWidget); // faded out + await tester.pump(const Duration(milliseconds: 1)); + expect(find.text('Open'), findsOneWidget); + expect(find.text('Closed'), findsNothing); + + final NavigatorState navigator = tester.state(find.byType(Navigator)); + navigator.pop(); + await tester.pump(); + + // Jump to the end of the transition. + await tester.pump(const Duration(seconds: 2)); + expect(find.text('Open'), findsOneWidget); // faded out + expect(find.text('Closed'), findsOneWidget); // faded in + await tester.pump(const Duration(milliseconds: 1)); + expect(find.text('Open'), findsNothing); + expect(find.text('Closed'), findsOneWidget); + }); + + testWidgets('can specify an open shape', (WidgetTester tester) async { + await tester.pumpWidget(Center( + child: SizedBox( + width: 300, + height: 400, + child: _boilerplate( + child: Center( + child: OpenContainer( + closedShape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + openShape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(40), + ), + closedBuilder: (BuildContext context, VoidCallback action) { + return const Text('Closed'); + }, + openBuilder: (BuildContext context, VoidCallback action) { + return const Text('Open'); + }, + ), + ), + ), + ), + )); + + expect(find.text('Open'), findsNothing); + expect(find.text('Closed'), findsOneWidget); + final double closedRadius = _getRadius(tester.firstWidget(find.ancestor( + of: find.text('Closed'), + matching: find.byType(Material), + ))); + expect(closedRadius, 10.0); + + await tester.tap(find.text('Closed')); + await tester.pump(); + expect(find.text('Open'), findsOneWidget); + expect(find.text('Closed'), findsOneWidget); + final double openingRadius = _getRadius(tester.firstWidget(find.ancestor( + of: find.text('Open'), + matching: find.byType(Material), + ))); + expect(openingRadius, 10.0); + + await tester.pump(const Duration(milliseconds: 150)); + expect(find.text('Open'), findsOneWidget); + expect(find.text('Closed'), findsOneWidget); + final double halfwayRadius = _getRadius(tester.firstWidget(find.ancestor( + of: find.text('Open'), + matching: find.byType(Material), + ))); + expect(halfwayRadius, greaterThan(10.0)); + expect(halfwayRadius, lessThan(40.0)); + + await tester.pump(const Duration(milliseconds: 150)); + expect(find.text('Open'), findsOneWidget); + expect(find.text('Closed'), findsOneWidget); + final double openRadius = _getRadius(tester.firstWidget(find.ancestor( + of: find.text('Open'), + matching: find.byType(Material), + ))); + expect(openRadius, 40.0); + + await tester.pump(const Duration(milliseconds: 1)); + expect(find.text('Closed'), findsNothing); + expect(find.text('Open'), findsOneWidget); + final double finalRadius = _getRadius(tester.firstWidget(find.ancestor( + of: find.text('Open'), + matching: find.byType(Material), + ))); + expect(finalRadius, 40.0); + }); +} + +void _expectMaterialPropertiesHaveAdvanced({ + @required _TrackedData biggerMaterial, + @required _TrackedData smallerMaterial, + @required WidgetTester tester, +}) { + expect(biggerMaterial.material.color, isNot(smallerMaterial.material.color)); + expect( + biggerMaterial.material.elevation, + greaterThan(smallerMaterial.material.elevation), + ); + expect(biggerMaterial.radius, lessThan(smallerMaterial.radius)); + expect(biggerMaterial.rect.height, greaterThan(smallerMaterial.rect.height)); + expect(biggerMaterial.rect.width, greaterThan(smallerMaterial.rect.width)); + expect(biggerMaterial.rect.top, lessThan(smallerMaterial.rect.top)); + expect(biggerMaterial.rect.left, lessThan(smallerMaterial.rect.left)); +} + +double _getOpacity(WidgetTester tester, String label) { + final Opacity widget = tester.firstWidget(find.ancestor( + of: find.text(label), + matching: find.byType(Opacity), + )); + return widget.opacity; +} + +class _TrackedData { + _TrackedData(this.material, this.rect); + + final Material material; + final Rect rect; + + double get radius => _getRadius(material); +} + +double _getRadius(Material material) { + final RoundedRectangleBorder shape = material.shape; + if (shape == null) { + return 0.0; + } + final BorderRadius radius = shape.borderRadius; + return radius.topRight.x; +} + +Widget _boilerplate({@required Widget child}) { + return MaterialApp( + home: Scaffold( + body: child, + ), + ); +}