diff --git a/packages/animations/lib/animations.dart b/packages/animations/lib/animations.dart index f58279ddaa58..bfeba39c7e9b 100644 --- a/packages/animations/lib/animations.dart +++ b/packages/animations/lib/animations.dart @@ -3,3 +3,4 @@ // found in the LICENSE file. export 'src/open_container.dart'; +export 'src/page_transition_switcher.dart'; diff --git a/packages/animations/lib/src/page_transition_switcher.dart b/packages/animations/lib/src/page_transition_switcher.dart new file mode 100644 index 000000000000..c94a1ffe1fc9 --- /dev/null +++ b/packages/animations/lib/src/page_transition_switcher.dart @@ -0,0 +1,381 @@ +// 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/animation.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +// Internal representation of a child that, now or in the past, was set on the +// PageTransitionSwitcher.child field, but is now in the process of +// transitioning. The internal representation includes fields that we don't want +// to expose to the public API (like the controllers). +class _ChildEntry { + _ChildEntry({ + @required this.primaryController, + @required this.secondaryController, + @required this.transition, + @required this.widgetChild, + }) : assert(primaryController != null), + assert(secondaryController != null), + assert(widgetChild != null), + assert(transition != null); + + final AnimationController primaryController; + + final AnimationController secondaryController; + + // The currently built transition for this child. + Widget transition; + + // The widget's child at the time this entry was created or updated. + // Used to rebuild the transition if necessary. + Widget widgetChild; + + void dispose() { + primaryController.dispose(); + secondaryController.dispose(); + } + + @override + String toString() { + return 'PageTransitionSwitcherEntry#${shortHash(this)}($widgetChild)'; + } +} + +/// Signature for builders used to generate custom transitions for +/// [PageTransitionSwitcher]. +/// +/// The function should return a widget which wraps the given `child`. +/// +/// When a [PageTransitionSwitcher]'s `child` is replaced, the new child's +/// `primaryAnimation` runs forward and the value of its `secondaryAnimation` is +/// usually fixed at 0.0. At the same time, the old child's `secondaryAnimation` +/// runs forward, and the value of its primaryAnimation is usually fixed at 1.0. +/// +/// The widget returned by the [PageTransitionSwitcherTransitionBuilder] can +/// incorporate both animations. It will use the primary animation to define how +/// its child appears, and the secondary animation to define how its child +/// disappears. +typedef PageTransitionSwitcherTransitionBuilder = Widget Function( + Widget child, + Animation primaryAnimation, + Animation secondaryAnimation, +); + +/// A widget that transitions from an old child to a new child whenever [child] +/// changes using an animation specified by [transitionBuilder]. +/// +/// This is a variation of an [AnimatedSwitcher], but instead of using the +/// same transition for enter and exit, two separate transitions can be +/// specified, similar to how the enter and exit transitions of a [PageRoute] +/// are defined. +/// +/// When a new [child] is specified, the [transitionBuilder] is effectively +/// applied twice, once to the old child and once to the new one. When +/// [reverse] is false, the old child's `secondaryAnimation` runs forward, and +/// the value of its `primaryAnimation` is usually fixed at 1.0. The new child's +/// `primaryAnimation` runs forward and the value of its `secondaryAnimation` is +/// usually fixed at 0.0. The widget returned by the [transitionBuilder] can +/// incorporate both animations. It will use the primary animation to define how +/// its child appears, and the secondary animation to define how its child +/// disappears. This is similar to the transition associated with pushing a new +/// [PageRoute] on top of another. +/// +/// When [reverse] is true, the old child's `primaryAnimation` runs in reverse +/// and the value of its `secondaryAnimation` is usually fixed at 0.0. The new +/// child's `secondaryAnimation` runs in reverse and the value of its +/// `primaryAnimation` is usually fixed at 1.0. This is similar to popping a +/// [PageRoute] to reveal another [PageRoute] underneath it. +/// +/// This process is the same as the one used by [PageRoute.buildTransitions]. +/// +/// The following example shows a [transitionBuilder] that slides out the +/// old child to the right (driven by the `secondaryAnimation`) while the new +/// child fades in (driven by the `primaryAnimation`): +/// +/// ```dart +/// transitionBuilder: ( +/// Widget child, +/// Animation primaryAnimation, +/// Animation secondaryAnimation, +/// ) { +/// return SlideTransition( +/// position: Tween( +/// begin: Offset.zero, +/// end: const Offset(1.5, 0.0), +/// ).animate(secondaryAnimation), +/// child: FadeTransition( +/// opacity: Tween( +/// begin: 0.0, +/// end: 1.0, +/// ).animate(primaryAnimation), +/// child: child, +/// ), +/// ); +/// }, +/// ``` +/// +/// If the children are swapped fast enough (i.e. before [duration] elapses), +/// more than one old child can exist and be transitioning out while the +/// newest one is transitioning in. +/// +/// If the *new* child is the same widget type and key as the *old* child, +/// but with different parameters, then [PageTransitionSwitcher] will *not* do a +/// transition between them, since as far as the framework is concerned, they +/// are the same widget and the existing widget can be updated with the new +/// parameters. To force the transition to occur, set a [Key] on each child +/// widget that you wish to be considered unique (typically a [ValueKey] on the +/// widget data that distinguishes this child from the others). For example, +/// changing the child from `SizedBox(width: 10)` to `SizedBox(width: 100)` +/// would not trigger a transition but changing the child from +/// `SizedBox(width: 10)` to `SizedBox(key: Key('foo'), width: 100)` would. +/// Similarly, changing the child to `Container(width: 10)` would trigger a +/// transition. +/// +/// The same key can be used for a new child as was used for an already-outgoing +/// child; the two will not be considered related. For example, if a progress +/// indicator with key A is first shown, then an image with key B, then another +/// progress indicator with key A again, all in rapid succession, then the old +/// progress indicator and the image will be fading out while a new progress +/// indicator is fading in. +class PageTransitionSwitcher extends StatefulWidget { + /// Creates a [PageTransitionSwitcher]. + /// + /// The [duration], [reverse], and [transitionBuilder] parameters + /// must not be null. + const PageTransitionSwitcher({ + Key key, + this.duration = const Duration(milliseconds: 300), + this.reverse = false, + @required this.transitionBuilder, + this.child, + }) : assert(duration != null), + assert(reverse != null), + assert(transitionBuilder != null), + super(key: key); + + /// The current child widget to display. + /// + /// If there was an old child, it will be transitioned out using the + /// secondary animation of the [transitionBuilder], while the new child + /// transitions in using the primary animation of the [transitionBuilder]. + /// + /// If there was no old child, then this child will transition in using + /// the primary animation of the [transitionBuilder]. + /// + /// The child is considered to be "new" if it has a different type or [Key] + /// (see [Widget.canUpdate]). + final Widget child; + + /// The duration of the transition from the old [child] value to the new one. + /// + /// This duration is applied to the given [child] when that property is set to + /// a new child. Changing [duration] will not affect the durations of + /// transitions already in progress. + final Duration duration; + + /// Indicates whether the new [child] will visually appear on top of or + /// underneath the old child. + /// + /// When this is false, the new child will transition in on top of the + /// old child while its primary animation and the secondary + /// animation of the old child are running forward. This is similar to + /// the transition associated with pushing a new [PageRoute] on top of + /// another. + /// + /// When this is true, the new child will transition in below the + /// old child while its secondary animation and the primary + /// animation of the old child are running in reverse. This is similar to + /// the transition associated with popping a [PageRoute] to reveal a new + /// [PageRoute] below it. + final bool reverse; + + /// A function that wraps a new [child] with a primary and secondary animation + /// set define how the child appears and disappears. + /// + /// This is only called when a new [child] is set (not for each build), or + /// when a new [transitionBuilder] is set. If a new [transitionBuilder] is + /// set, then the transition is rebuilt for the current child and all old + /// children using the new [transitionBuilder]. The function must not return + /// null. + /// + /// The child provided to the transitionBuilder may be null. + final PageTransitionSwitcherTransitionBuilder transitionBuilder; + + @override + _PageTransitionSwitcherState createState() => _PageTransitionSwitcherState(); +} + +class _PageTransitionSwitcherState extends State + with TickerProviderStateMixin { + final List<_ChildEntry> _activeEntries = <_ChildEntry>[]; + _ChildEntry _currentEntry; + int _childNumber = 0; + + @override + void initState() { + super.initState(); + _addEntryForNewChild(shouldAnimate: false); + } + + @override + void didUpdateWidget(PageTransitionSwitcher oldWidget) { + super.didUpdateWidget(oldWidget); + + // If the transition builder changed, then update all of the old + // transitions. + if (widget.transitionBuilder != oldWidget.transitionBuilder) { + _activeEntries.forEach(_updateTransitionForEntry); + } + + final bool hasNewChild = widget.child != null; + final bool hasOldChild = _currentEntry != null; + if (hasNewChild != hasOldChild || + hasNewChild && + !Widget.canUpdate(widget.child, _currentEntry.widgetChild)) { + // Child has changed, fade current entry out and add new entry. + _childNumber += 1; + _addEntryForNewChild(shouldAnimate: true); + } else if (_currentEntry != null) { + assert(hasOldChild && hasNewChild); + assert(Widget.canUpdate(widget.child, _currentEntry.widgetChild)); + // Child has been updated. Make sure we update the child widget and + // transition in _currentEntry even though we're not going to start a new + // animation, but keep the key from the old transition so that we + // update the transition instead of replacing it. + _currentEntry.widgetChild = widget.child; + _updateTransitionForEntry(_currentEntry); // uses entry.widgetChild + } + } + + void _addEntryForNewChild({@required bool shouldAnimate}) { + assert(shouldAnimate || _currentEntry == null); + if (_currentEntry != null) { + assert(shouldAnimate); + if (widget.reverse) { + _currentEntry.primaryController.reverse(); + } else { + _currentEntry.secondaryController.forward(); + } + _currentEntry = null; + } + if (widget.child == null) { + return; + } + final AnimationController primaryController = AnimationController( + duration: widget.duration, + vsync: this, + ); + final AnimationController secondaryController = AnimationController( + duration: widget.duration, + vsync: this, + ); + if (shouldAnimate) { + if (widget.reverse) { + primaryController.value = 1.0; + secondaryController.value = 1.0; + secondaryController.reverse(); + } else { + primaryController.forward(); + } + } else { + assert(_activeEntries.isEmpty); + primaryController.value = 1.0; + } + _currentEntry = _newEntry( + child: widget.child, + primaryController: primaryController, + secondaryController: secondaryController, + builder: widget.transitionBuilder, + ); + if (widget.reverse && _activeEntries.isNotEmpty) { + // Add below old child. + _activeEntries.insert(_activeEntries.length - 1, _currentEntry); + } else { + // Add on top of old child. + _activeEntries.add(_currentEntry); + } + } + + _ChildEntry _newEntry({ + @required Widget child, + @required PageTransitionSwitcherTransitionBuilder builder, + @required AnimationController primaryController, + @required AnimationController secondaryController, + }) { + final Widget transition = builder( + child, + primaryController, + secondaryController, + ); + assert( + transition != null, + 'PageTransitionSwitcher.builder must not return null.', + ); + final _ChildEntry entry = _ChildEntry( + widgetChild: child, + transition: KeyedSubtree.wrap( + transition, + _childNumber, + ), + primaryController: primaryController, + secondaryController: secondaryController, + ); + secondaryController.addStatusListener((AnimationStatus status) { + if (status == AnimationStatus.completed) { + assert(mounted); + assert(_activeEntries.contains(entry)); + setState(() { + _activeEntries.remove(entry); + entry.dispose(); + }); + } + }); + primaryController.addStatusListener((AnimationStatus status) { + if (status == AnimationStatus.dismissed) { + assert(mounted); + assert(_activeEntries.contains(entry)); + setState(() { + _activeEntries.remove(entry); + entry.dispose(); + }); + } + }); + return entry; + } + + void _updateTransitionForEntry(_ChildEntry entry) { + final Widget transition = widget.transitionBuilder( + entry.widgetChild, + entry.primaryController, + entry.secondaryController, + ); + assert( + transition != null, + 'PageTransitionSwitcher.builder must not return null.', + ); + entry.transition = KeyedSubtree( + key: entry.transition.key, + child: transition, + ); + } + + @override + void dispose() { + for (_ChildEntry entry in _activeEntries) { + entry.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: _activeEntries + .map((_ChildEntry entry) => entry.transition) + .toList(), + alignment: Alignment.center, + ); + } +} diff --git a/packages/animations/pubspec.yaml b/packages/animations/pubspec.yaml index 4679d9b67b64..6bfc61343745 100644 --- a/packages/animations/pubspec.yaml +++ b/packages/animations/pubspec.yaml @@ -4,7 +4,7 @@ version: 0.0.1 homepage: https://github.com/flutter/packages/tree/master/packages/animations environment: - sdk: ">=2.1.0 <3.0.0" + sdk: ">=2.2.2 <3.0.0" dependencies: flutter: diff --git a/packages/animations/test/page_transition_switcher_test.dart b/packages/animations/test/page_transition_switcher_test.dart new file mode 100644 index 000000000000..27659c01cc93 --- /dev/null +++ b/packages/animations/test/page_transition_switcher_test.dart @@ -0,0 +1,628 @@ +// 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/animations.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('transitions in a new child.', (WidgetTester tester) async { + final UniqueKey containerOne = UniqueKey(); + final UniqueKey containerTwo = UniqueKey(); + final UniqueKey containerThree = UniqueKey(); + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(key: containerOne, color: const Color(0x00000000)), + transitionBuilder: _transitionBuilder, + ), + ); + + Map _primaryAnimation = + _getPrimaryAnimation([containerOne], tester); + Map _secondaryAnimation = + _getSecondaryAnimation([containerOne], tester); + expect(_primaryAnimation[containerOne], equals(1.0)); + expect(_secondaryAnimation[containerOne], equals(0.0)); + + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(key: containerTwo, color: const Color(0xff000000)), + transitionBuilder: _transitionBuilder, + ), + ); + await tester.pump(const Duration(milliseconds: 40)); + + _primaryAnimation = + _getPrimaryAnimation([containerOne, containerTwo], tester); + _secondaryAnimation = + _getSecondaryAnimation([containerOne, containerTwo], tester); + // Secondary is running for outgoing widget. + expect(_primaryAnimation[containerOne], equals(1.0)); + expect(_secondaryAnimation[containerOne], moreOrLessEquals(0.4)); + // Primary is running for incoming widget. + expect(_primaryAnimation[containerTwo], moreOrLessEquals(0.4)); + expect(_secondaryAnimation[containerTwo], equals(0.0)); + + // Container one is underneath container two + final Container container = tester.firstWidget(find.byType(Container)); + expect(container.key, containerOne); + + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(key: containerThree, color: const Color(0xffff0000)), + transitionBuilder: _transitionBuilder, + ), + ); + await tester.pump(const Duration(milliseconds: 20)); + + _primaryAnimation = _getPrimaryAnimation( + [containerOne, containerTwo, containerThree], tester); + _secondaryAnimation = _getSecondaryAnimation( + [containerOne, containerTwo, containerThree], tester); + expect(_primaryAnimation[containerOne], equals(1.0)); + expect(_secondaryAnimation[containerOne], equals(0.6)); + expect(_primaryAnimation[containerTwo], equals(0.6)); + expect(_secondaryAnimation[containerTwo], moreOrLessEquals(0.2)); + expect(_primaryAnimation[containerThree], moreOrLessEquals(0.2)); + expect(_secondaryAnimation[containerThree], equals(0.0)); + await tester.pumpAndSettle(); + }); + + testWidgets('transitions in a new child in reverse.', + (WidgetTester tester) async { + final UniqueKey containerOne = UniqueKey(); + final UniqueKey containerTwo = UniqueKey(); + final UniqueKey containerThree = UniqueKey(); + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(key: containerOne, color: const Color(0x00000000)), + transitionBuilder: _transitionBuilder, + reverse: true, + ), + ); + + Map _primaryAnimation = + _getPrimaryAnimation([containerOne], tester); + Map _secondaryAnimation = + _getSecondaryAnimation([containerOne], tester); + expect(_primaryAnimation[containerOne], equals(1.0)); + expect(_secondaryAnimation[containerOne], equals(0.0)); + + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(key: containerTwo, color: const Color(0xff000000)), + transitionBuilder: _transitionBuilder, + reverse: true, + ), + ); + await tester.pump(const Duration(milliseconds: 40)); + + _primaryAnimation = + _getPrimaryAnimation([containerOne, containerTwo], tester); + _secondaryAnimation = + _getSecondaryAnimation([containerOne, containerTwo], tester); + // Primary is running forward for outgoing widget. + expect(_primaryAnimation[containerOne], moreOrLessEquals(0.6)); + expect(_secondaryAnimation[containerOne], equals(0.0)); + // Secondary is running forward for incoming widget. + expect(_primaryAnimation[containerTwo], equals(1.0)); + expect(_secondaryAnimation[containerTwo], moreOrLessEquals(0.6)); + + // Container two two is underneath container one. + final Container container = tester.firstWidget(find.byType(Container)); + expect(container.key, containerTwo); + + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(key: containerThree, color: const Color(0xffff0000)), + transitionBuilder: _transitionBuilder, + reverse: true, + ), + ); + await tester.pump(const Duration(milliseconds: 20)); + + _primaryAnimation = _getPrimaryAnimation( + [containerOne, containerTwo, containerThree], tester); + _secondaryAnimation = _getSecondaryAnimation( + [containerOne, containerTwo, containerThree], tester); + expect(_primaryAnimation[containerOne], equals(0.4)); + expect(_secondaryAnimation[containerOne], equals(0.0)); + expect(_primaryAnimation[containerTwo], equals(0.8)); + expect(_secondaryAnimation[containerTwo], equals(0.4)); + expect(_primaryAnimation[containerThree], equals(1.0)); + expect(_secondaryAnimation[containerThree], equals(0.8)); + await tester.pumpAndSettle(); + }); + + testWidgets('switch from forward to reverse', (WidgetTester tester) async { + final UniqueKey containerOne = UniqueKey(); + final UniqueKey containerTwo = UniqueKey(); + final UniqueKey containerThree = UniqueKey(); + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(key: containerOne, color: const Color(0x00000000)), + transitionBuilder: _transitionBuilder, + ), + ); + + Map _primaryAnimation = + _getPrimaryAnimation([containerOne], tester); + Map _secondaryAnimation = + _getSecondaryAnimation([containerOne], tester); + expect(_primaryAnimation[containerOne], equals(1.0)); + expect(_secondaryAnimation[containerOne], equals(0.0)); + + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(key: containerTwo, color: const Color(0xff000000)), + transitionBuilder: _transitionBuilder, + ), + ); + await tester.pump(const Duration(milliseconds: 40)); + + _primaryAnimation = + _getPrimaryAnimation([containerOne, containerTwo], tester); + _secondaryAnimation = + _getSecondaryAnimation([containerOne, containerTwo], tester); + expect(_secondaryAnimation[containerOne], moreOrLessEquals(0.4)); + expect(_primaryAnimation[containerOne], equals(1.0)); + expect(_secondaryAnimation[containerTwo], equals(0.0)); + expect(_primaryAnimation[containerTwo], moreOrLessEquals(0.4)); + + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(key: containerThree, color: const Color(0xffff0000)), + transitionBuilder: _transitionBuilder, + reverse: true, + ), + ); + await tester.pump(const Duration(milliseconds: 20)); + + _primaryAnimation = _getPrimaryAnimation( + [containerOne, containerTwo, containerThree], tester); + _secondaryAnimation = _getSecondaryAnimation( + [containerOne, containerTwo, containerThree], tester); + expect(_secondaryAnimation[containerOne], equals(0.6)); + expect(_primaryAnimation[containerOne], equals(1.0)); + expect(_secondaryAnimation[containerTwo], equals(0.0)); + expect(_primaryAnimation[containerTwo], moreOrLessEquals(0.2)); + expect(_secondaryAnimation[containerThree], equals(0.8)); + expect(_primaryAnimation[containerThree], equals(1.0)); + await tester.pumpAndSettle(); + }); + + testWidgets('switch from reverse to forward.', (WidgetTester tester) async { + final UniqueKey containerOne = UniqueKey(); + final UniqueKey containerTwo = UniqueKey(); + final UniqueKey containerThree = UniqueKey(); + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(key: containerOne, color: const Color(0x00000000)), + transitionBuilder: _transitionBuilder, + reverse: true, + ), + ); + + Map _primaryAnimation = + _getPrimaryAnimation([containerOne], tester); + Map _secondaryAnimation = + _getSecondaryAnimation([containerOne], tester); + expect(_primaryAnimation[containerOne], equals(1.0)); + expect(_secondaryAnimation[containerOne], equals(0.0)); + + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(key: containerTwo, color: const Color(0xff000000)), + transitionBuilder: _transitionBuilder, + reverse: true, + ), + ); + await tester.pump(const Duration(milliseconds: 40)); + + _primaryAnimation = + _getPrimaryAnimation([containerOne, containerTwo], tester); + _secondaryAnimation = + _getSecondaryAnimation([containerOne, containerTwo], tester); + // Primary is running in reverse for outgoing widget. + expect(_primaryAnimation[containerOne], moreOrLessEquals(0.6)); + expect(_secondaryAnimation[containerOne], equals(0.0)); + // Secondary is running in reverse for incoming widget. + expect(_primaryAnimation[containerTwo], equals(1.0)); + expect(_secondaryAnimation[containerTwo], moreOrLessEquals(0.6)); + + // Container two is underneath container one. + final Container container = tester.firstWidget(find.byType(Container)); + expect(container.key, containerTwo); + + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(key: containerThree, color: const Color(0xffff0000)), + transitionBuilder: _transitionBuilder, + reverse: false, + ), + ); + await tester.pump(const Duration(milliseconds: 20)); + + // Container one is expected to continue running its primary animation in + // reverse since it is exiting. Container two's secondary animation switches + // from running its secondary animation in reverse to running forwards since + // it should now be exiting underneath container three. Container three's + // primary animation should be running forwards since it is entering above + // container two. + _primaryAnimation = _getPrimaryAnimation( + [containerOne, containerTwo, containerThree], tester); + _secondaryAnimation = _getSecondaryAnimation( + [containerOne, containerTwo, containerThree], tester); + expect(_primaryAnimation[containerOne], equals(0.4)); + expect(_secondaryAnimation[containerOne], equals(0.0)); + expect(_primaryAnimation[containerTwo], equals(1.0)); + expect(_secondaryAnimation[containerTwo], equals(0.8)); + expect(_primaryAnimation[containerThree], moreOrLessEquals(0.2)); + expect(_secondaryAnimation[containerThree], equals(0.0)); + await tester.pumpAndSettle(); + }); + + testWidgets("doesn't transition in a new child of the same type.", + (WidgetTester tester) async { + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(color: const Color(0x00000000)), + transitionBuilder: _transitionBuilder, + ), + ); + + expect(find.byType(FadeTransition), findsOneWidget); + expect(find.byType(ScaleTransition), findsOneWidget); + FadeTransition fade = tester.firstWidget(find.byType(FadeTransition)); + ScaleTransition scale = tester.firstWidget(find.byType(ScaleTransition)); + expect(fade.opacity.value, equals(1.0)); + expect(scale.scale.value, equals(1.0)); + + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(color: const Color(0xff000000)), + transitionBuilder: _transitionBuilder, + ), + ); + + await tester.pump(const Duration(milliseconds: 50)); + expect(find.byType(FadeTransition), findsOneWidget); + expect(find.byType(ScaleTransition), findsOneWidget); + fade = tester.firstWidget(find.byType(FadeTransition)); + scale = tester.firstWidget(find.byType(ScaleTransition)); + expect(fade.opacity.value, equals(1.0)); + expect(scale.scale.value, equals(1.0)); + await tester.pumpAndSettle(); + }); + + testWidgets('handles null children.', (WidgetTester tester) async { + await tester.pumpWidget( + const PageTransitionSwitcher( + duration: Duration(milliseconds: 100), + child: null, + transitionBuilder: _transitionBuilder, + ), + ); + + expect(find.byType(FadeTransition), findsNothing); + expect(find.byType(ScaleTransition), findsNothing); + + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(color: const Color(0xff000000)), + transitionBuilder: _transitionBuilder, + ), + ); + + await tester.pump(const Duration(milliseconds: 40)); + expect(find.byType(FadeTransition), findsOneWidget); + expect(find.byType(ScaleTransition), findsOneWidget); + FadeTransition fade = tester.firstWidget(find.byType(FadeTransition)); + ScaleTransition scale = tester.firstWidget(find.byType(ScaleTransition)); + expect(fade.opacity.value, equals(1.0)); + expect(scale.scale.value, moreOrLessEquals(0.4)); + await tester.pumpAndSettle(); // finish transitions. + + await tester.pumpWidget( + const PageTransitionSwitcher( + duration: Duration(milliseconds: 100), + child: null, + transitionBuilder: _transitionBuilder, + ), + ); + + await tester.pump(const Duration(milliseconds: 50)); + expect(find.byType(FadeTransition), findsOneWidget); + expect(find.byType(ScaleTransition), findsOneWidget); + fade = tester.firstWidget(find.byType(FadeTransition)); + scale = tester.firstWidget(find.byType(ScaleTransition)); + expect(fade.opacity.value, equals(0.5)); + expect(scale.scale.value, equals(1.0)); + await tester.pumpAndSettle(); + }); + + testWidgets("doesn't start any animations after dispose.", + (WidgetTester tester) async { + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(key: UniqueKey(), color: const Color(0xff000000)), + transitionBuilder: _transitionBuilder, + ), + ); + + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(key: UniqueKey(), color: const Color(0xff000000)), + transitionBuilder: _transitionBuilder, + ), + ); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.byType(FadeTransition), findsNWidgets(2)); + expect(find.byType(ScaleTransition), findsNWidgets(2)); + final FadeTransition fade = tester.firstWidget(find.byType(FadeTransition)); + final ScaleTransition scale = + tester.firstWidget(find.byType(ScaleTransition)); + expect(fade.opacity.value, equals(0.5)); + expect(scale.scale.value, equals(1.0)); + + // Change the widget tree in the middle of the animation. + await tester.pumpWidget(Container(color: const Color(0xffff0000))); + expect(await tester.pumpAndSettle(const Duration(milliseconds: 100)), + equals(1)); + }); + + testWidgets("doesn't reset state of the children in transitions.", + (WidgetTester tester) async { + final UniqueKey statefulOne = UniqueKey(); + final UniqueKey statefulTwo = UniqueKey(); + final UniqueKey statefulThree = UniqueKey(); + + StatefulTestWidgetState.generation = 0; + + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: StatefulTestWidget(key: statefulOne), + transitionBuilder: _transitionBuilder, + ), + ); + + Map _primaryAnimation = + _getPrimaryAnimation([statefulOne], tester); + Map _secondaryAnimation = + _getSecondaryAnimation([statefulOne], tester); + expect(_primaryAnimation[statefulOne], equals(1.0)); + expect(_secondaryAnimation[statefulOne], equals(0.0)); + expect(StatefulTestWidgetState.generation, equals(1)); + + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: StatefulTestWidget(key: statefulTwo), + transitionBuilder: _transitionBuilder, + ), + ); + + await tester.pump(const Duration(milliseconds: 50)); + expect(find.byType(FadeTransition), findsNWidgets(2)); + _primaryAnimation = + _getPrimaryAnimation([statefulOne, statefulTwo], tester); + _secondaryAnimation = + _getSecondaryAnimation([statefulOne, statefulTwo], tester); + expect(_primaryAnimation[statefulTwo], equals(0.5)); + expect(_secondaryAnimation[statefulTwo], equals(0.0)); + expect(StatefulTestWidgetState.generation, equals(2)); + + await tester.pumpWidget( + PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: StatefulTestWidget(key: statefulThree), + transitionBuilder: _transitionBuilder, + ), + ); + + await tester.pump(const Duration(milliseconds: 10)); + expect(StatefulTestWidgetState.generation, equals(3)); + await tester.pumpAndSettle(); + expect(StatefulTestWidgetState.generation, equals(3)); + }); + + testWidgets('updates widgets without animating if they are isomorphic.', + (WidgetTester tester) async { + Future pumpChild(Widget child) async { + return tester.pumpWidget( + Directionality( + textDirection: TextDirection.rtl, + child: PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: child, + transitionBuilder: _transitionBuilder, + ), + ), + ); + } + + await pumpChild(const Text('1')); + await tester.pump(const Duration(milliseconds: 10)); + FadeTransition fade = tester.widget(find.byType(FadeTransition)); + ScaleTransition scale = tester.widget(find.byType(ScaleTransition)); + expect(fade.opacity.value, equals(1.0)); + expect(scale.scale.value, equals(1.0)); + expect(find.text('1'), findsOneWidget); + expect(find.text('2'), findsNothing); + await pumpChild(const Text('2')); + fade = tester.widget(find.byType(FadeTransition)); + scale = tester.widget(find.byType(ScaleTransition)); + await tester.pump(const Duration(milliseconds: 20)); + expect(fade.opacity.value, equals(1.0)); + expect(scale.scale.value, equals(1.0)); + expect(find.text('1'), findsNothing); + expect(find.text('2'), findsOneWidget); + }); + + testWidgets( + 'updates previous child transitions if the transitionBuilder changes.', + (WidgetTester tester) async { + final UniqueKey containerOne = UniqueKey(); + final UniqueKey containerTwo = UniqueKey(); + final UniqueKey containerThree = UniqueKey(); + + // Insert three unique children so that we have some previous children. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(key: containerOne, color: const Color(0xFFFF0000)), + transitionBuilder: _transitionBuilder, + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 10)); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(key: containerTwo, color: const Color(0xFF00FF00)), + transitionBuilder: _transitionBuilder, + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 10)); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(key: containerThree, color: const Color(0xFF0000FF)), + transitionBuilder: _transitionBuilder, + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 10)); + + expect(find.byType(FadeTransition), findsNWidgets(3)); + expect(find.byType(ScaleTransition), findsNWidgets(3)); + expect(find.byType(SlideTransition), findsNothing); + expect(find.byType(SizeTransition), findsNothing); + + Widget newTransitionBuilder( + Widget child, Animation primary, Animation secondary) { + return SlideTransition( + position: Tween(begin: Offset.zero, end: const Offset(20, 30)) + .animate(primary), + child: SizeTransition( + sizeFactor: Tween(begin: 10, end: 0.0).animate(secondary), + child: child, + ), + ); + } + + // Now set a new transition builder and make sure all the previous + // transitions are replaced. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: PageTransitionSwitcher( + duration: const Duration(milliseconds: 100), + child: Container(key: containerThree, color: const Color(0x00000000)), + transitionBuilder: newTransitionBuilder, + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 10)); + + expect(find.byType(FadeTransition), findsNothing); + expect(find.byType(ScaleTransition), findsNothing); + expect(find.byType(SlideTransition), findsNWidgets(3)); + expect(find.byType(SizeTransition), findsNWidgets(3)); + }); +} + +class StatefulTestWidget extends StatefulWidget { + const StatefulTestWidget({Key key}) : super(key: key); + + @override + StatefulTestWidgetState createState() => StatefulTestWidgetState(); +} + +class StatefulTestWidgetState extends State { + StatefulTestWidgetState(); + static int generation = 0; + + @override + void initState() { + super.initState(); + generation++; + } + + @override + Widget build(BuildContext context) => Container(); +} + +Widget _transitionBuilder( + Widget child, Animation primary, Animation secondary) { + return ScaleTransition( + scale: Tween(begin: 0.0, end: 1.0).animate(primary), + child: FadeTransition( + opacity: Tween(begin: 1.0, end: 0.0).animate(secondary), + child: child, + ), + ); +} + +Map _getSecondaryAnimation(List keys, WidgetTester tester) { + expect(find.byType(FadeTransition), findsNWidgets(keys.length)); + final Map result = {}; + for (Key key in keys) { + final FadeTransition transition = tester.firstWidget( + find.ancestor( + of: find.byKey(key), + matching: find.byType(FadeTransition), + ), + ); + result[key] = 1.0 - transition.opacity.value; + } + return result; +} + +Map _getPrimaryAnimation(List keys, WidgetTester tester) { + expect(find.byType(ScaleTransition), findsNWidgets(keys.length)); + final Map result = {}; + for (Key key in keys) { + final ScaleTransition transition = tester.firstWidget( + find.ancestor( + of: find.byKey(key), + matching: find.byType(ScaleTransition), + ), + ); + result[key] = transition.scale.value; + } + return result; +}