From ae4cffd48d312c8c3f82e4259e032195c6fe0fa7 Mon Sep 17 00:00:00 2001 From: Kate Lovett Date: Wed, 29 Apr 2026 15:04:13 -0500 Subject: [PATCH] Trying out go_router support for OpenContainer --- packages/animations/CHANGELOG.md | 4 + packages/animations/README.md | 28 ++ .../example/lib/go_router_example.dart | 118 +++++ packages/animations/example/lib/main.dart | 14 + packages/animations/example/pubspec.yaml | 1 + .../animations/lib/src/open_container.dart | 419 ++++++++++++++---- packages/animations/pubspec.yaml | 2 +- .../test/open_container_declarative_test.dart | 142 ++++++ 8 files changed, 632 insertions(+), 96 deletions(-) create mode 100644 packages/animations/example/lib/go_router_example.dart create mode 100644 packages/animations/test/open_container_declarative_test.dart diff --git a/packages/animations/CHANGELOG.md b/packages/animations/CHANGELOG.md index 2dc7bbdf6186..511d73955077 100644 --- a/packages/animations/CHANGELOG.md +++ b/packages/animations/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.3.0 + +* Added OpenContainerPage and onOpen hook to OpenContainer for go_router compatibility. + ## 2.2.0 * Adds support for custom `closedShadows` and `openShadows` to `OpenContainer`. diff --git a/packages/animations/README.md b/packages/animations/README.md index ebb63b8cc978..8657915a8cbf 100644 --- a/packages/animations/README.md +++ b/packages/animations/README.md @@ -72,3 +72,31 @@ _Examples of the fade pattern:_ 2. _A menu_ 3. _A snackbar_ 4. _A FAB_ + +## Usage with Declarative Routers (e.g. go_router) + +The `OpenContainer` widget can be integrated with declarative routers like `go_router` to ensure that the browser URL updates when the container opens, while still preserving the container transform animation. + +To do this, use the `onOpen` hook to trigger the navigation and `OpenContainerPage` in your route definition. Both must share the same `transitionTag`. + +```dart +// In your list page: +OpenContainer( + transitionTag: 'item-${item.id}', + onOpen: () => context.push('/details/${item.id}'), + closedBuilder: (context, action) => MyListTile(onTap: action), + openBuilder: (context, action) => MyDetailsPage(id: item.id), +) + +// In your router configuration: +GoRoute( + path: '/details/:id', + pageBuilder: (context, state) { + final String id = state.pathParameters['id']!; + return OpenContainerPage( + transitionTag: 'item-$id', + openBuilder: (context, action) => MyDetailsPage(id: id), + ); + }, +) +``` diff --git a/packages/animations/example/lib/go_router_example.dart b/packages/animations/example/lib/go_router_example.dart new file mode 100644 index 000000000000..88aabfed7403 --- /dev/null +++ b/packages/animations/example/lib/go_router_example.dart @@ -0,0 +1,118 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:animations/animations.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +void main() { + runApp(const GoRouterExampleApp()); +} + +/// An example of integrating package:animations with package:go_router. +class GoRouterExampleApp extends StatelessWidget { + /// Creates a [GoRouterExampleApp]. + const GoRouterExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + title: 'OpenContainer go_router Example', + routerConfig: _router, + ); + } +} + +final GoRouter _router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) { + return const HomeScreen(); + }, + routes: [ + GoRoute( + path: 'details/:id', + pageBuilder: (BuildContext context, GoRouterState state) { + final String id = state.pathParameters['id']!; + return OpenContainerPage( + transitionTag: 'item-$id', + openBuilder: (BuildContext context, VoidCallback closeContainer) { + return DetailsScreen(id: id); + }, + ); + }, + ), + ], + ), + ], +); + +/// The home screen of the go_router example. +class HomeScreen extends StatelessWidget { + /// Creates a [HomeScreen]. + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('OpenContainer go_router Example')), + body: ListView.builder( + itemCount: 20, + itemBuilder: (BuildContext context, int index) { + final id = index.toString(); + return OpenContainer( + transitionTag: 'item-$id', + onOpen: () { + context.go('/details/$id'); + return Future.value(); + }, + closedBuilder: (BuildContext context, VoidCallback openContainer) { + return ListTile( + onTap: openContainer, + title: Text('Item $id'), + subtitle: const Text('Tap to open with container transform'), + ); + }, + openBuilder: (BuildContext context, VoidCallback closeContainer) { + return DetailsScreen(id: id); + }, + ); + }, + ), + ); + } +} + +/// The details screen of the go_router example. +class DetailsScreen extends StatelessWidget { + /// Creates a [DetailsScreen]. + const DetailsScreen({super.key, required this.id}); + + /// The ID of the item to display. + final String id; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Details of Item $id')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Details for Item $id', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => context.pop(), + child: const Text('Go Back'), + ), + ], + ), + ), + ); + } +} diff --git a/packages/animations/example/lib/main.dart b/packages/animations/example/lib/main.dart index e06b7c9808e3..98db0a8c7700 100644 --- a/packages/animations/example/lib/main.dart +++ b/packages/animations/example/lib/main.dart @@ -8,6 +8,7 @@ import 'package:flutter/scheduler.dart'; import 'container_transition.dart'; import 'fade_scale_transition.dart'; import 'fade_through_transition.dart'; +import 'go_router_example.dart'; import 'shared_axis_transition.dart'; void main() { @@ -94,6 +95,19 @@ class _TransitionsHomePageState extends State<_TransitionsHomePage> { ); }, ), + _TransitionListTile( + title: 'go_router integration', + subtitle: 'OpenContainerPage', + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const GoRouterExampleApp(); + }, + ), + ); + }, + ), ], ), ), diff --git a/packages/animations/example/pubspec.yaml b/packages/animations/example/pubspec.yaml index 3a5b1b207c9b..addc9649ff0e 100644 --- a/packages/animations/example/pubspec.yaml +++ b/packages/animations/example/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: cupertino_icons: ^1.0.2 flutter: sdk: flutter + go_router: ^14.0.0 dev_dependencies: flutter_test: diff --git a/packages/animations/lib/src/open_container.dart b/packages/animations/lib/src/open_container.dart index a9d90f7b73e3..baf541295aca 100644 --- a/packages/animations/lib/src/open_container.dart +++ b/packages/animations/lib/src/open_container.dart @@ -139,6 +139,8 @@ class OpenContainer extends StatefulWidget { this.clipBehavior = Clip.antiAlias, this.closedShadows, this.openShadows, + this.transitionTag, + this.onOpen, }); /// Background color of the container while it is closed. @@ -311,10 +313,170 @@ class OpenContainer extends StatefulWidget { /// If this is provided, [openElevation] will be ignored. final List? openShadows; + /// An optional tag to identify this [OpenContainer]. + /// + /// This tag can be used by an [OpenContainerPage] to find this container + /// and perform the container transform animation when the page is pushed + /// declaratively (e.g. via `go_router`). + final Object? transitionTag; + + /// An optional callback to trigger the opening of the container. + /// + /// If this is provided, it will be called instead of the default + /// [Navigator.push] when the container is tapped or when + /// [OpenContainerState.openContainer] is called. + /// + /// This is useful for integrating with declarative routers like `go_router`. + /// For example: + /// + /// ```dart + /// OpenContainer( + /// onOpen: () => context.push('/details'), + /// // ... + /// ) + /// ``` + final Future Function()? onOpen; + @override State> createState() => OpenContainerState(); } +/// A page that shows the container transform animation. +/// +/// This is used for integrating with declarative routers like `go_router`. +/// It uses the [transitionTag] to find the source [OpenContainer] and perform +/// the container transform animation. +/// +/// If the source [OpenContainer] is not found (e.g. direct URL navigation), +/// it will perform a simple fade-in transition using the provided properties. +class OpenContainerPage extends Page { + /// Creates an [OpenContainerPage]. + const OpenContainerPage({ + this.closedColor = Colors.white, + this.openColor = Colors.white, + this.middleColor, + 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.openBuilder, + this.transitionDuration = const Duration(milliseconds: 300), + this.transitionType = ContainerTransitionType.fade, + this.useRootNavigator = false, + this.closedShadows, + this.openShadows, + this.transitionTag, + super.key, + super.name, + super.arguments, + super.restorationId, + }); + + /// Background color of the container while it is closed. + final Color closedColor; + + /// Background color of the container while it is open. + final Color openColor; + + /// The color to use for the background color during the transition. + final Color? middleColor; + + /// Elevation of the container while it is closed. + final double closedElevation; + + /// Elevation of the container while it is open. + final double openElevation; + + /// Shape of the container while it is closed. + final ShapeBorder closedShape; + + /// Shape of the container while it is open. + final ShapeBorder openShape; + + /// Called to obtain the child for the container in the open state. + final OpenContainerBuilder openBuilder; + + /// The time it will take to animate the transition. + final Duration transitionDuration; + + /// The type of fade transition that the container will use. + final ContainerTransitionType transitionType; + + /// Whether to use the root navigator. + final bool useRootNavigator; + + /// Custom shadows of the container while it is closed. + final List? closedShadows; + + /// Custom shadows of the container while it is open. + final List? openShadows; + + /// The tag of the source [OpenContainer]. + final Object? transitionTag; + + @override + Route createRoute(BuildContext context) { + OpenContainerState? state; + if (transitionTag != null) { + state = OpenContainerRegistry.instance.get(transitionTag!); + } + + return OpenContainerRoute( + closedColor: state?.widget.closedColor ?? closedColor, + openColor: state?.widget.openColor ?? openColor, + middleColor: state?.widget.middleColor ?? + middleColor ?? + Theme.of(context).canvasColor, + closedElevation: state?.widget.closedElevation ?? closedElevation, + openElevation: state?.widget.openElevation ?? openElevation, + closedShape: state?.widget.closedShape ?? closedShape, + openShape: state?.widget.openShape ?? openShape, + closedBuilder: state?.widget.closedBuilder, + openBuilder: openBuilder, + hideableKey: state?.hideableKey, + closedBuilderKey: state?.closedBuilderKey, + transitionDuration: + state?.widget.transitionDuration ?? transitionDuration, + transitionType: state?.widget.transitionType ?? transitionType, + useRootNavigator: state?.widget.useRootNavigator ?? useRootNavigator, + routeSettings: this, + closedShadows: state?.widget.closedShadows ?? closedShadows, + openShadows: state?.widget.openShadows ?? openShadows, + ); + } +} + +/// A registry that tracks active [OpenContainerState] instances by their +/// [OpenContainer.transitionTag]. +/// +/// This is used by [OpenContainerPage] to coordinate the container transform +/// animation between the source [OpenContainer] and the destination page. +class OpenContainerRegistry { + OpenContainerRegistry._(); + + static final OpenContainerRegistry _instance = OpenContainerRegistry._(); + + /// Returns the singleton instance of the registry. + static OpenContainerRegistry get instance => _instance; + + final Map> _states = >{}; + + /// Registers an [OpenContainerState] with the given [tag]. + void register(Object tag, OpenContainerState state) { + _states[tag] = state; + } + + /// Unregisters the [OpenContainerState] associated with the given [tag]. + void unregister(Object tag) { + _states.remove(tag); + } + + /// Returns the [OpenContainerState] associated with the given [tag], if any. + OpenContainerState? get(Object tag) => _states[tag]; +} + /// State for a [OpenContainer]. /// /// The [OpenContainerState.openContainer] can be triggered either by: @@ -323,21 +485,58 @@ class OpenContainer extends StatefulWidget { /// if [OpenContainer.tappable] is true. @optionalTypeArgs 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(); + /// 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 hideableKey = GlobalKey(); + + /// 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(); + + @override + void initState() { + super.initState(); + if (widget.transitionTag != null) { + OpenContainerRegistry.instance.register(widget.transitionTag!, this); + } + } + + @override + void didUpdateWidget(OpenContainer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.transitionTag != widget.transitionTag) { + if (oldWidget.transitionTag != null) { + OpenContainerRegistry.instance.unregister(oldWidget.transitionTag!); + } + if (widget.transitionTag != null) { + OpenContainerRegistry.instance.register(widget.transitionTag!, this); + } + } + } + + @override + void dispose() { + if (widget.transitionTag != null) { + OpenContainerRegistry.instance.unregister(widget.transitionTag!); + } + super.dispose(); + } /// Open the container using the given middle color and specific route, /// then call `onClosed` with the returned data after popped. Future openContainer() async { + if (widget.onOpen != null) { + final T? data = await widget.onOpen!(); + if (widget.onClosed != null) { + widget.onClosed!(data); + } + return; + } + final Color middleColor = widget.middleColor ?? Theme.of(context).canvasColor; final T? data = @@ -345,7 +544,7 @@ class OpenContainerState extends State> { context, rootNavigator: widget.useRootNavigator, ).push( - _OpenContainerRoute( + OpenContainerRoute( closedColor: widget.closedColor, openColor: widget.openColor, middleColor: middleColor, @@ -355,8 +554,8 @@ class OpenContainerState extends State> { openShape: widget.openShape, closedBuilder: widget.closedBuilder, openBuilder: widget.openBuilder, - hideableKey: _hideableKey, - closedBuilderKey: _closedBuilderKey, + hideableKey: hideableKey, + closedBuilderKey: closedBuilderKey, transitionDuration: widget.transitionDuration, transitionType: widget.transitionType, useRootNavigator: widget.useRootNavigator, @@ -378,15 +577,15 @@ class OpenContainerState extends State> { elevation: widget.closedShadows == null ? widget.closedElevation : 0.0, shape: widget.closedShape, child: Builder( - key: _closedBuilderKey, + key: closedBuilderKey, builder: (BuildContext context) { return widget.closedBuilder(context, openContainer); }, ), ); - return _Hideable( - key: _hideableKey, + return Hideable( + key: hideableKey, child: GestureDetector( onTap: widget.tappable ? openContainer : null, child: widget.closedShadows == null @@ -414,16 +613,19 @@ class OpenContainerState extends State> { /// * 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({super.key, required this.child}); +class Hideable extends StatefulWidget { + /// Creates a [Hideable]. + const Hideable({super.key, required this.child}); + /// The widget below this widget in the tree. final Widget child; @override - State<_Hideable> createState() => _HideableState(); + State createState() => HideableState(); } -class _HideableState extends State<_Hideable> { +/// State for [Hideable]. +class HideableState extends State { /// When non-null the child is replaced by a [SizedBox] of the set size. Size? get placeholderSize => _placeholderSize; Size? _placeholderSize; @@ -471,8 +673,10 @@ class _HideableState extends State<_Hideable> { } } -class _OpenContainerRoute extends ModalRoute { - _OpenContainerRoute({ +/// A route that shows the container transform animation. +class OpenContainerRoute extends ModalRoute { + /// Creates an [OpenContainerRoute]. + OpenContainerRoute({ required this.closedColor, required this.openColor, required this.middleColor, @@ -480,13 +684,13 @@ class _OpenContainerRoute extends ModalRoute { required this.openElevation, required ShapeBorder closedShape, required this.openShape, - required this.closedBuilder, + this.closedBuilder, required this.openBuilder, - required this.hideableKey, - required this.closedBuilderKey, + this.hideableKey, + this.closedBuilderKey, required this.transitionDuration, required this.transitionType, - required this.useRootNavigator, + this.useRootNavigator = false, required RouteSettings? routeSettings, required this.closedShadows, required this.openShadows, @@ -506,7 +710,7 @@ class _OpenContainerRoute extends ModalRoute { _openOpacityTween = _getOpenOpacityTween(transitionType), super(settings: routeSettings); - static _FlippableTweenSequence _getColorTween({ + static FlippableTweenSequence _getColorTween({ required ContainerTransitionType transitionType, required Color closedColor, required Color openColor, @@ -514,7 +718,7 @@ class _OpenContainerRoute extends ModalRoute { }) { switch (transitionType) { case ContainerTransitionType.fade: - return _FlippableTweenSequence(>[ + return FlippableTweenSequence(>[ TweenSequenceItem( tween: ConstantTween(closedColor), weight: 1 / 5, @@ -529,7 +733,7 @@ class _OpenContainerRoute extends ModalRoute { ), ]); case ContainerTransitionType.fadeThrough: - return _FlippableTweenSequence(>[ + return FlippableTweenSequence(>[ TweenSequenceItem( tween: ColorTween(begin: closedColor, end: middleColor), weight: 1 / 5, @@ -542,19 +746,19 @@ class _OpenContainerRoute extends ModalRoute { } } - static _FlippableTweenSequence _getClosedOpacityTween( + static FlippableTweenSequence _getClosedOpacityTween( ContainerTransitionType transitionType, ) { switch (transitionType) { case ContainerTransitionType.fade: - return _FlippableTweenSequence(>[ + return FlippableTweenSequence(>[ TweenSequenceItem( tween: ConstantTween(1.0), weight: 1, ), ]); case ContainerTransitionType.fadeThrough: - return _FlippableTweenSequence(>[ + return FlippableTweenSequence(>[ TweenSequenceItem( tween: Tween(begin: 1.0, end: 0.0), weight: 1 / 5, @@ -567,12 +771,12 @@ class _OpenContainerRoute extends ModalRoute { } } - static _FlippableTweenSequence _getOpenOpacityTween( + static FlippableTweenSequence _getOpenOpacityTween( ContainerTransitionType transitionType, ) { switch (transitionType) { case ContainerTransitionType.fade: - return _FlippableTweenSequence(>[ + return FlippableTweenSequence(>[ TweenSequenceItem( tween: ConstantTween(0.0), weight: 1 / 5, @@ -587,7 +791,7 @@ class _OpenContainerRoute extends ModalRoute { ), ]); case ContainerTransitionType.fadeThrough: - return _FlippableTweenSequence(>[ + return FlippableTweenSequence(>[ TweenSequenceItem( tween: ConstantTween(0.0), weight: 1 / 5, @@ -600,34 +804,54 @@ class _OpenContainerRoute extends ModalRoute { } } + /// Background color of the container while it is closed. final Color closedColor; + + /// Background color of the container while it is open. final Color openColor; + + /// The color to use for the background color during the transition. final Color middleColor; + + /// Elevation of the container while it is open. final double openElevation; + + /// Shape of the container while it is open. final ShapeBorder openShape; - final CloseContainerBuilder closedBuilder; + + /// Called to obtain the child for the container in the closed state. + final CloseContainerBuilder? closedBuilder; + + /// Called to obtain the child for the container in the open state. final OpenContainerBuilder openBuilder; + + /// Custom shadows of the container while it is closed. final List? closedShadows; + + /// Custom shadows of the container while it is open. final List? openShadows; - // See [_OpenContainerState._hideableKey]. - final GlobalKey<_HideableState> hideableKey; + /// See [OpenContainerState.hideableKey]. + final GlobalKey? hideableKey; - // See [_OpenContainerState._closedBuilderKey]. - final GlobalKey closedBuilderKey; + /// See [OpenContainerState.closedBuilderKey]. + final GlobalKey? closedBuilderKey; @override final Duration transitionDuration; + + /// The type of fade transition that the container will use. final ContainerTransitionType transitionType; + /// Whether to use the root navigator. final bool useRootNavigator; final Tween _elevationTween; final Animatable?> _shadowsTween; final ShapeBorderTween _shapeTween; - final _FlippableTweenSequence _closedOpacityTween; - final _FlippableTweenSequence _openOpacityTween; - final _FlippableTweenSequence _colorTween; + final FlippableTweenSequence _closedOpacityTween; + final FlippableTweenSequence _openOpacityTween; + final FlippableTweenSequence _colorTween; static final TweenSequence _scrimFadeInTween = TweenSequence(>[ @@ -662,15 +886,17 @@ class _OpenContainerRoute extends ModalRoute { if (begin == null && end == null) { return ConstantTween?>(null); } - return _ShadowsTween(begin: begin, end: end); + return ShadowsTween(begin: begin, end: end); } AnimationStatus? _lastAnimationStatus; AnimationStatus? _currentAnimationStatus; + bool get _isCoordinated => hideableKey != null && closedBuilderKey != null && closedBuilder != null; + @override TickerFuture didPush() { - _takeMeasurements(navigatorContext: hideableKey.currentContext!); + _takeMeasurements(); animation!.addStatusListener((AnimationStatus status) { _lastAnimationStatus = _currentAnimationStatus; @@ -691,16 +917,15 @@ class _OpenContainerRoute extends ModalRoute { @override bool didPop(T? result) { - _takeMeasurements( - navigatorContext: subtreeContext!, - delayForSourceRoute: true, - ); + if (_isCoordinated) { + _takeMeasurements(delayForSourceRoute: true); + } return super.didPop(result); } @override void dispose() { - if (hideableKey.currentState?.isVisible == false) { + if (_isCoordinated && hideableKey!.currentState?.isVisible == false) { // This route may be disposed without dismissing its animation if it is // removed by the navigator. SchedulerBinding.instance.addPostFrameCallback( @@ -711,39 +936,34 @@ class _OpenContainerRoute extends ModalRoute { } void _toggleHideable({required bool hide}) { - if (hideableKey.currentState != null) { - hideableKey.currentState! + if (_isCoordinated && hideableKey!.currentState != null) { + hideableKey!.currentState! ..placeholderSize = null ..isVisible = !hide; } } void _takeMeasurements({ - required BuildContext navigatorContext, bool delayForSourceRoute = false, }) { - final navigator = - Navigator.of( - navigatorContext, - rootNavigator: useRootNavigator, - ).context.findRenderObject()! - as RenderBox; - final Size navSize = _getSize(navigator); + final navigatorBox = + navigator!.context.findRenderObject()! as RenderBox; + final Size navSize = _getSize(navigatorBox); _rectTween.end = Offset.zero & navSize; void takeMeasurementsInSourceRoute([Duration? _]) { - if (!navigator.attached || hideableKey.currentContext == null) { + if (!navigatorBox.attached || hideableKey?.currentContext == null) { return; } - _rectTween.begin = _getRect(hideableKey, navigator); - hideableKey.currentState!.placeholderSize = _rectTween.begin!.size; + _rectTween.begin = _getRect(hideableKey!, navigatorBox); + hideableKey!.currentState!.placeholderSize = _rectTween.begin!.size; } if (delayForSourceRoute) { SchedulerBinding.instance.addPostFrameCallback( takeMeasurementsInSourceRoute, ); - } else { + } else if (_isCoordinated) { takeMeasurementsInSourceRoute(); } } @@ -793,6 +1013,7 @@ class _OpenContainerRoute extends ModalRoute { return wasInProgress && isInProgress; } + /// Closes the container. void closeContainer({T? returnValue}) { Navigator.of(subtreeContext!).pop(returnValue); } @@ -871,7 +1092,9 @@ class _OpenContainerRoute extends ModalRoute { assert(openOpacityTween != null); assert(scrimTween != null); - final Rect rect = _rectTween.evaluate(curvedAnimation)!; + final Rect? rect = _rectTween.begin == null + ? _rectTween.end + : _rectTween.evaluate(curvedAnimation); final Widget material = Material( clipBehavior: Clip.antiAlias, animationDuration: Duration.zero, @@ -882,27 +1105,28 @@ class _OpenContainerRoute extends ModalRoute { 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, () {}); - }, + if (_isCoordinated) + 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( @@ -936,10 +1160,10 @@ class _OpenContainerRoute extends ModalRoute { child: Align( alignment: Alignment.topLeft, child: Transform.translate( - offset: Offset(rect.left, rect.top), + offset: rect == null ? Offset.zero : Offset(rect.left, rect.top), child: SizedBox( - width: rect.width, - height: rect.height, + width: rect?.width ?? 0.0, + height: rect?.height ?? 0.0, child: currentShadows == null ? material : DecoratedBox( @@ -975,13 +1199,16 @@ class _OpenContainerRoute extends ModalRoute { String? get barrierLabel => null; } -class _FlippableTweenSequence extends TweenSequence { - _FlippableTweenSequence(this._items) : super(_items); +/// A [TweenSequence] that can be flipped. +class FlippableTweenSequence extends TweenSequence { + /// Creates a [FlippableTweenSequence]. + FlippableTweenSequence(this._items) : super(_items); final List> _items; - _FlippableTweenSequence? _flipped; + FlippableTweenSequence? _flipped; - _FlippableTweenSequence? get flipped { + /// Returns a flipped version of this [TweenSequence]. + FlippableTweenSequence? get flipped { if (_flipped == null) { final newItems = >[]; for (var i = 0; i < _items.length; i++) { @@ -992,14 +1219,16 @@ class _FlippableTweenSequence extends TweenSequence { ), ); } - _flipped = _FlippableTweenSequence(newItems); + _flipped = FlippableTweenSequence(newItems); } return _flipped; } } -class _ShadowsTween extends Tween?> { - _ShadowsTween({super.begin, super.end}); +/// A [Tween] that interpolates between two lists of [BoxShadow]s. +class ShadowsTween extends Tween?> { + /// Creates a [ShadowsTween]. + ShadowsTween({super.begin, super.end}); @override List? lerp(double t) { diff --git a/packages/animations/pubspec.yaml b/packages/animations/pubspec.yaml index 5f95401cb958..bc5cbb0395f1 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.2.0 +version: 2.3.0 environment: sdk: ^3.9.0 diff --git a/packages/animations/test/open_container_declarative_test.dart b/packages/animations/test/open_container_declarative_test.dart new file mode 100644 index 000000000000..a717431ecd9a --- /dev/null +++ b/packages/animations/test/open_container_declarative_test.dart @@ -0,0 +1,142 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:animations/animations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('OpenContainer can be opened via onOpen hook', (WidgetTester tester) async { + var onOpenCalled = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: OpenContainer( + onOpen: () { + onOpenCalled = true; + return Future.value(); + }, + closedBuilder: (BuildContext context, VoidCallback openContainer) { + return ElevatedButton( + onPressed: openContainer, + child: const Text('Open'), + ); + }, + openBuilder: (BuildContext context, VoidCallback closeContainer) { + return const Text('Opened'); + }, + ), + ), + ), + ); + + expect(onOpenCalled, isFalse); + await tester.tap(find.text('Open')); + await tester.pump(); + expect(onOpenCalled, isTrue); + }); + + testWidgets('OpenContainer registers itself in OpenContainerRegistry', (WidgetTester tester) async { + const tag = 'test-tag'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: OpenContainer( + transitionTag: tag, + closedBuilder: (BuildContext context, VoidCallback openContainer) { + return const Text('Closed'); + }, + openBuilder: (BuildContext context, VoidCallback closeContainer) { + return const Text('Opened'); + }, + ), + ), + ), + ); + + final OpenContainerState? state = OpenContainerRegistry.instance.get(tag); + expect(state, isNotNull); + expect(state!.widget.transitionTag, tag); + + await tester.pumpWidget(Container()); + expect(OpenContainerRegistry.instance.get(tag), isNull); + }); + + testWidgets('OpenContainerPage performs coordinated transition', (WidgetTester tester) async { + const tag = 'coordinated-tag'; + final navKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + navigatorKey: navKey, + home: Scaffold( + body: OpenContainer( + transitionTag: tag, + closedBuilder: (BuildContext context, VoidCallback openContainer) { + return const SizedBox( + width: 100, + height: 100, + child: Text('Closed Content'), + ); + }, + openBuilder: (BuildContext context, VoidCallback closeContainer) { + return const Text('Opened Content'); + }, + ), + ), + ), + ); + + expect(find.text('Closed Content'), findsOneWidget); + expect(find.text('Opened Content'), findsNothing); + + navKey.currentState!.push( + OpenContainerPage( + transitionTag: tag, + openBuilder: (BuildContext context, VoidCallback closeContainer) { + return const Scaffold(body: Text('Opened Content')); + }, + ).createRoute(tester.element(find.byType(OpenContainer))), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + + // During transition, both should be present (though one might be fading out) + expect(find.text('Closed Content'), findsOneWidget); + expect(find.text('Opened Content'), findsOneWidget); + + await tester.pumpAndSettle(); + expect(find.text('Closed Content'), findsNothing); + expect(find.text('Opened Content'), findsOneWidget); + }); + + testWidgets('OpenContainerPage falls back to fade when tag not found', (WidgetTester tester) async { + final navKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + navigatorKey: navKey, + home: const Scaffold( + body: Text('Home'), + ), + ), + ); + + navKey.currentState!.push( + OpenContainerPage( + transitionTag: 'non-existent', + openBuilder: (BuildContext context, VoidCallback closeContainer) { + return const Scaffold(body: Text('Opened Content')); + }, + ).createRoute(tester.element(find.text('Home'))), + ); + + await tester.pump(); + // It should just work without crashing + expect(find.text('Opened Content'), findsOneWidget); + await tester.pumpAndSettle(); + expect(find.text('Opened Content'), findsOneWidget); + }); +}