Unverified Commit 731e9819 authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Allow "from" hero state to survive hero animation in a push transition (#32842)

parent d310d31d
...@@ -2174,8 +2174,9 @@ CreateRectTween _linearTranslateWithLargestRectSizeTween = (Rect begin, Rect end ...@@ -2174,8 +2174,9 @@ CreateRectTween _linearTranslateWithLargestRectSizeTween = (Rect begin, Rect end
); );
}; };
final TransitionBuilder _navBarHeroLaunchPadBuilder = ( final HeroPlaceholderBuilder _navBarHeroLaunchPadBuilder = (
BuildContext context, BuildContext context,
Size heroSize,
Widget child, Widget child,
) { ) {
assert(child is _TransitionableNavigationBar); assert(child is _TransitionableNavigationBar);
......
...@@ -11,6 +11,7 @@ import 'navigator.dart'; ...@@ -11,6 +11,7 @@ import 'navigator.dart';
import 'overlay.dart'; import 'overlay.dart';
import 'pages.dart'; import 'pages.dart';
import 'routes.dart'; import 'routes.dart';
import 'ticker_provider.dart' show TickerMode;
import 'transitions.dart'; import 'transitions.dart';
/// Signature for a function that takes two [Rect] instances and returns a /// Signature for a function that takes two [Rect] instances and returns a
...@@ -21,6 +22,22 @@ import 'transitions.dart'; ...@@ -21,6 +22,22 @@ import 'transitions.dart';
/// [MaterialRectArcTween]. /// [MaterialRectArcTween].
typedef CreateRectTween = Tween<Rect> Function(Rect begin, Rect end); typedef CreateRectTween = Tween<Rect> Function(Rect begin, Rect end);
/// Signature for a function that builds a [Hero] placeholder widget given a
/// child and a [Size].
///
/// The child can optionally be part of the returned widget tree. The returned
/// widget should typically be constrained to [heroSize], if it doesn't do so
/// implicitly.
///
/// See also:
/// * [TransitionBuilder], which is similar but only takes a [BuildContext]
/// and a child widget.
typedef HeroPlaceholderBuilder = Widget Function(
BuildContext context,
Size heroSize,
Widget child,
);
/// A function that lets [Hero]es self supply a [Widget] that is shown during the /// A function that lets [Hero]es self supply a [Widget] that is shown during the
/// hero's flight from one route to another instead of default (which is to /// hero's flight from one route to another instead of default (which is to
/// show the destination route's instance of the Hero). /// show the destination route's instance of the Hero).
...@@ -189,13 +206,30 @@ class Hero extends StatefulWidget { ...@@ -189,13 +206,30 @@ class Hero extends StatefulWidget {
/// ///
/// If none is provided, the destination route's Hero child is shown in-flight /// If none is provided, the destination route's Hero child is shown in-flight
/// by default. /// by default.
///
/// ## Limitations
///
/// If a widget built by [flightShuttleBuilder] takes part in a [Navigator]
/// push transition, that widget or its descendants must not have any
/// [GlobalKey] that is used in the source Hero's descendant widgets. That is
/// because both subtrees will be included in the widget tree during the Hero
/// flight animation, and [GlobalKey]s must be unique across the entire widget
/// tree.
///
/// If the said [GlobalKey] is essential to your application, consider providing
/// a custom [placeholderBuilder] for the source Hero, to avoid the [GlobalKey]
/// collision, such as a builder that builds an empty [SizedBox], keeping the
/// Hero [child]'s original size.
final HeroFlightShuttleBuilder flightShuttleBuilder; final HeroFlightShuttleBuilder flightShuttleBuilder;
/// Placeholder widget left in place as the Hero's child once the flight takes off. /// Placeholder widget left in place as the Hero's [child] once the flight takes
/// off.
/// ///
/// By default, an empty SizedBox keeping the Hero child's original size is /// By default the placeholder widget is an empty [SizedBox] keeping the Hero
/// left in place once the Hero shuttle has taken flight. /// child's original size, unless this Hero is a source Hero of a [Navigator]
final TransitionBuilder placeholderBuilder; /// push transition, in which case [child] will be a descendant of the placeholder
/// and will be kept [Offstage] during the Hero's flight.
final HeroPlaceholderBuilder placeholderBuilder;
/// Whether to perform the hero transition if the [PageRoute] transition was /// Whether to perform the hero transition if the [PageRoute] transition was
/// triggered by a user gesture, such as a back swipe on iOS. /// triggered by a user gesture, such as a back swipe on iOS.
...@@ -285,8 +319,24 @@ class Hero extends StatefulWidget { ...@@ -285,8 +319,24 @@ class Hero extends StatefulWidget {
class _HeroState extends State<Hero> { class _HeroState extends State<Hero> {
final GlobalKey _key = GlobalKey(); final GlobalKey _key = GlobalKey();
Size _placeholderSize; Size _placeholderSize;
// Whether the placeholder widget should wrap the hero's child widget as its
void startFlight() { // own child, when `_placeholderSize` is non-null (i.e. the hero is currently
// in its flight animation). See `startFlight`.
bool _shouldIncludeChild = true;
// The `shouldIncludeChildInPlaceholder` flag dictates if the child widget of
// this hero should be included in the placeholder widget as a descendant.
//
// When a new hero flight animation takes place, a placeholder widget
// needs to be built to replace the original hero widget. When
// `shouldIncludeChildInPlaceholder` is set to true and `widget.placeholderBuilder`
// is null, the placeholder widget will include the original hero's child
// widget as a descendant, allowing the orignal element tree to be preserved.
//
// It is typically set to true for the *from* hero in a push transition,
// and false otherwise.
void startFlight({ bool shouldIncludedChildInPlaceholder = false }) {
_shouldIncludeChild = shouldIncludedChildInPlaceholder;
assert(mounted); assert(mounted);
final RenderBox box = context.findRenderObject(); final RenderBox box = context.findRenderObject();
assert(box != null && box.hasSize); assert(box != null && box.hasSize);
...@@ -310,19 +360,29 @@ class _HeroState extends State<Hero> { ...@@ -310,19 +360,29 @@ class _HeroState extends State<Hero> {
'A Hero widget cannot be the descendant of another Hero widget.' 'A Hero widget cannot be the descendant of another Hero widget.'
); );
if (_placeholderSize != null) { final bool isHeroInFlight = _placeholderSize != null;
if (widget.placeholderBuilder == null) {
if (isHeroInFlight && widget.placeholderBuilder != null) {
return widget.placeholderBuilder(context, _placeholderSize, widget.child);
}
if (isHeroInFlight && !_shouldIncludeChild) {
return SizedBox( return SizedBox(
width: _placeholderSize.width, width: _placeholderSize.width,
height: _placeholderSize.height, height: _placeholderSize.height,
); );
} else {
return widget.placeholderBuilder(context, widget.child);
} }
}
return KeyedSubtree( return SizedBox(
key: _key, width: _placeholderSize?.width,
child: widget.child, height: _placeholderSize?.height,
child: Offstage(
offstage: isHeroInFlight,
child: TickerMode(
enabled: !isHeroInFlight,
child: KeyedSubtree(key: _key, child: widget.child),
)
),
); );
} }
} }
...@@ -496,7 +556,7 @@ class _HeroFlight { ...@@ -496,7 +556,7 @@ class _HeroFlight {
else else
_proxyAnimation.parent = manifest.animation; _proxyAnimation.parent = manifest.animation;
manifest.fromHero.startFlight(); manifest.fromHero.startFlight(shouldIncludedChildInPlaceholder: manifest.type == HeroFlightDirection.push);
manifest.toHero.startFlight(); manifest.toHero.startFlight();
heroRectTween = _doCreateRectTween( heroRectTween = _doCreateRectTween(
...@@ -574,7 +634,7 @@ class _HeroFlight { ...@@ -574,7 +634,7 @@ class _HeroFlight {
manifest.toHero.endFlight(); manifest.toHero.endFlight();
// Let the heroes in each of the routes rebuild with their placeholders. // Let the heroes in each of the routes rebuild with their placeholders.
newManifest.fromHero.startFlight(); newManifest.fromHero.startFlight(shouldIncludedChildInPlaceholder: newManifest.type == HeroFlightDirection.push);
newManifest.toHero.startFlight(); newManifest.toHero.startFlight();
// Let the transition overlay on top of the routes also rebuild since // Let the transition overlay on top of the routes also rebuild since
......
...@@ -2,11 +2,26 @@ ...@@ -2,11 +2,26 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:ui' as ui;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import '../painting/image_test_utils.dart' show TestImageProvider;
Future<ui.Image> createTestImage() {
final ui.Paint paint = ui.Paint()
..style = ui.PaintingStyle.stroke
..strokeWidth = 1.0;
final ui.PictureRecorder recorder = ui.PictureRecorder();
final ui.Canvas pictureCanvas = ui.Canvas(recorder);
pictureCanvas.drawCircle(Offset.zero, 20.0, paint);
final ui.Picture picture = recorder.endRecording();
return picture.toImage(300, 300);
}
Key firstKey = const Key('first'); Key firstKey = const Key('first');
Key secondKey = const Key('second'); Key secondKey = const Key('second');
Key thirdKey = const Key('third'); Key thirdKey = const Key('third');
...@@ -124,6 +139,19 @@ class MutatingRoute extends MaterialPageRoute<void> { ...@@ -124,6 +139,19 @@ class MutatingRoute extends MaterialPageRoute<void> {
} }
} }
class _SimpleStatefulWidget extends StatefulWidget {
const _SimpleStatefulWidget({ Key key }) : super(key: key);
@override
_SimpleState createState() => _SimpleState();
}
class _SimpleState extends State<_SimpleStatefulWidget> {
int state = 0;
@override
Widget build(BuildContext context) => Text(state.toString());
}
class MyStatefulWidget extends StatefulWidget { class MyStatefulWidget extends StatefulWidget {
const MyStatefulWidget({ Key key, this.value = '123' }) : super(key: key); const MyStatefulWidget({ Key key, this.value = '123' }) : super(key: key);
final String value; final String value;
...@@ -136,7 +164,9 @@ class MyStatefulWidgetState extends State<MyStatefulWidget> { ...@@ -136,7 +164,9 @@ class MyStatefulWidgetState extends State<MyStatefulWidget> {
Widget build(BuildContext context) => Text(widget.value); Widget build(BuildContext context) => Text(widget.value);
} }
void main() { Future<void> main() async {
final ui.Image testImage = await createTestImage();
setUp(() { setUp(() {
transitionFromUserGestures = false; transitionFromUserGestures = false;
}); });
...@@ -168,16 +198,22 @@ void main() { ...@@ -168,16 +198,22 @@ void main() {
// seeing them at t=16ms. The original page no longer contains the hero. // seeing them at t=16ms. The original page no longer contains the hero.
expect(find.byKey(firstKey), findsNothing); expect(find.byKey(firstKey), findsNothing);
expect(find.byKey(secondKey), isOnstage);
expect(find.byKey(secondKey), findsOneWidget);
expect(find.byKey(secondKey), isNotInCard); expect(find.byKey(secondKey), isNotInCard);
expect(find.byKey(secondKey), isOnstage);
await tester.pump(); await tester.pump();
// t=32ms for the journey. Surely they are still at it. // t=32ms for the journey. Surely they are still at it.
expect(find.byKey(firstKey), findsNothing); expect(find.byKey(firstKey), findsNothing);
expect(find.byKey(secondKey), isOnstage);
expect(find.byKey(secondKey), findsOneWidget);
expect(find.byKey(secondKey), findsOneWidget);
expect(find.byKey(secondKey), isNotInCard); expect(find.byKey(secondKey), isNotInCard);
expect(find.byKey(secondKey), isOnstage);
await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1));
...@@ -883,7 +919,14 @@ void main() { ...@@ -883,7 +919,14 @@ void main() {
children: <Widget>[ children: <Widget>[
// This container will appear at Y=0 // This container will appear at Y=0
Container( Container(
child: Hero(tag: 'BC', child: Container(key: heroBCKey, height: 150.0)), child: Hero(
tag: 'BC',
child: Container(
key: heroBCKey,
height: 150.0,
child: const Text('Hero'),
)
),
), ),
const SizedBox(height: 800.0), const SizedBox(height: 800.0),
], ],
...@@ -901,14 +944,27 @@ void main() { ...@@ -901,14 +944,27 @@ void main() {
const SizedBox(height: 100.0), const SizedBox(height: 100.0),
// This container will appear at Y=100 // This container will appear at Y=100
Container( Container(
child: Hero(tag: 'AB', child: Container(key: heroABKey, height: 200.0)), child: Hero(
tag: 'AB',
child: Container(
key: heroABKey,
height: 200.0,
child: const Text('Hero'),
)
),
), ),
FlatButton( FlatButton(
child: const Text('PUSH C'), child: const Text('PUSH C'),
onPressed: () { Navigator.push(context, routeC); }, onPressed: () { Navigator.push(context, routeC); },
), ),
Container( Container(
child: Hero(tag: 'BC', child: Container(height: 150.0)), child: Hero(
tag: 'BC',
child: Container(
height: 150.0,
child: const Text('Hero'),
)
),
), ),
const SizedBox(height: 800.0), const SizedBox(height: 800.0),
], ],
...@@ -928,7 +984,14 @@ void main() { ...@@ -928,7 +984,14 @@ void main() {
const SizedBox(height: 200.0), const SizedBox(height: 200.0),
// This container will appear at Y=200 // This container will appear at Y=200
Container( Container(
child: Hero(tag: 'AB', child: Container(height: 100.0, width: 100.0)), child: Hero(
tag: 'AB',
child: Container(
height: 100.0,
width: 100.0,
child: const Text('Hero'),
)
),
), ),
FlatButton( FlatButton(
child: const Text('PUSH B'), child: const Text('PUSH B'),
...@@ -966,10 +1029,22 @@ void main() { ...@@ -966,10 +1029,22 @@ void main() {
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
expect(tester.getTopLeft(find.byKey(heroABKey)).dy, 100.0); expect(tester.getTopLeft(find.byKey(heroABKey)).dy, 100.0);
// One Opacity widget per Hero, only one now has opacity 0.0 bool _isVisible(Element node) {
final Iterable<RenderOpacity> renderers = tester.renderObjectList(find.byType(Opacity)); bool isVisible = true;
final Iterable<double> opacities = renderers.map<double>((RenderOpacity r) => r.opacity); node.visitAncestorElements((Element ancestor) {
expect(opacities.singleWhere((double opacity) => opacity == 0.0), 0.0); final RenderObject r = ancestor.renderObject;
if (r is RenderOpacity && r.opacity == 0) {
isVisible = false;
return false;
}
return true;
});
return isVisible;
}
// Of all heroes only one should be visible now.
final Iterable<Element> elements = find.text('Hero').evaluate();
expect(elements.where(_isVisible).length, 1);
// Hero BC's flight finishes normally. // Hero BC's flight finishes normally.
await tester.pump(const Duration(milliseconds: 300)); await tester.pump(const Duration(milliseconds: 300));
...@@ -1038,6 +1113,7 @@ void main() { ...@@ -1038,6 +1113,7 @@ void main() {
// Push flight underway. // Push flight underway.
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
// Visible in the hero animation.
expect(find.text('456'), findsOneWidget); expect(find.text('456'), findsOneWidget);
// Push flight finished. // Push flight finished.
...@@ -1439,7 +1515,7 @@ void main() { ...@@ -1439,7 +1515,7 @@ void main() {
Hero( Hero(
tag: 'a', tag: 'a',
child: const Text('Batman'), child: const Text('Batman'),
placeholderBuilder: (BuildContext context, Widget child) { placeholderBuilder: (BuildContext context, Size heroSize, Widget child) {
return const Text('Venom'); return const Text('Venom');
}, },
), ),
...@@ -1452,7 +1528,7 @@ void main() { ...@@ -1452,7 +1528,7 @@ void main() {
child: Hero( child: Hero(
tag: 'a', tag: 'a',
child: const Text('Wolverine'), child: const Text('Wolverine'),
placeholderBuilder: (BuildContext context, Widget child) { placeholderBuilder: (BuildContext context, Size size, Widget child) {
return const Text('Joker'); return const Text('Joker');
}, },
), ),
...@@ -1925,7 +2001,7 @@ void main() { ...@@ -1925,7 +2001,7 @@ void main() {
// Since we're popping, only the destination route's builder is used. // Since we're popping, only the destination route's builder is used.
flightShuttleBuilder: shuttleBuilder, flightShuttleBuilder: shuttleBuilder,
transitionOnUserGestures: true, transitionOnUserGestures: true,
child: const Text('1') child: const Text('1'),
), ),
), ),
); );
...@@ -1936,7 +2012,7 @@ void main() { ...@@ -1936,7 +2012,7 @@ void main() {
child: Hero( child: Hero(
tag: navigatorKey, tag: navigatorKey,
transitionOnUserGestures: true, transitionOnUserGestures: true,
child: const Text('2') child: const Text('2'),
), ),
); );
} }
...@@ -1965,4 +2041,156 @@ void main() { ...@@ -1965,4 +2041,156 @@ void main() {
// Still one shuttle. // Still one shuttle.
expect(shuttlesBuilt, 2); expect(shuttlesBuilt, 2);
}); });
testWidgets("From hero's state should be preserved, "
'heroes work well with child widgets that has global keys',
(WidgetTester tester) async {
final GlobalKey<NavigatorState> navigatorKey = GlobalKey();
final GlobalKey<_SimpleState> key1 = GlobalKey<_SimpleState>();
final GlobalKey key2 = GlobalKey();
await tester.pumpWidget(
CupertinoApp(
navigatorKey: navigatorKey,
home: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Hero(
tag: 'hero',
transitionOnUserGestures: true,
child: _SimpleStatefulWidget(key: key1),
),
const SizedBox(
width: 10,
height: 10,
child: Text('1'),
)
]
)
),
);
final CupertinoPageRoute<void> route2 = CupertinoPageRoute<void>(
builder: (BuildContext context) {
return CupertinoPageScaffold(
child: Hero(
tag: 'hero',
transitionOnUserGestures: true,
// key2 is a `GlobalKey`. The hero animation should not
// assert by having the same global keyed widget in more
// than one place in the tree.
child: _SimpleStatefulWidget(key: key2),
),
);
}
);
final _SimpleState state1 = key1.currentState;
state1.state = 1;
navigatorKey.currentState.push(route2);
await tester.pump();
expect(state1.mounted, isTrue);
await tester.pumpAndSettle();
expect(state1.state, 1);
// The element should be mounted and unique.
expect(state1.mounted, isTrue);
expect(navigatorKey.currentState.pop(), isTrue);
await tester.pumpAndSettle();
// State is preserved.
expect(state1.state, 1);
// The element should be mounted and unique.
expect(state1.mounted, isTrue);
});
testWidgets("Hero works with images that don't have both width and height specified",
// Regression test for https://github.com/flutter/flutter/issues/32356
// and https://github.com/flutter/flutter/issues/31503
(WidgetTester tester) async {
final GlobalKey<NavigatorState> navigatorKey = GlobalKey();
const Key imageKey1 = Key('image1');
const Key imageKey2 = Key('image2');
final TestImageProvider imageProvider = TestImageProvider(testImage);
await tester.pumpWidget(
CupertinoApp(
navigatorKey: navigatorKey,
home: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Hero(
tag: 'hero',
transitionOnUserGestures: true,
child: Container(
width: 100,
child: Image(
image: imageProvider,
key: imageKey1,
)
)
),
const SizedBox(
width: 10,
height: 10,
child: Text('1'),
)
]
)
),
);
final CupertinoPageRoute<void> route2 = CupertinoPageRoute<void>(
builder: (BuildContext context) {
return CupertinoPageScaffold(
child: Hero(
tag: 'hero',
transitionOnUserGestures: true,
child: Container(
child: Image(
image: imageProvider,
key: imageKey2,
)
)
),
);
}
);
// Load image before measuring the `Rect` of the `RenderImage`.
imageProvider.complete();
await tester.pump();
final RenderImage renderImage = tester.renderObject(
find.descendant(of: find.byKey(imageKey1), matching: find.byType(RawImage))
);
// Before push image1 should be laid out correctly.
expect(renderImage.size, const Size(100, 100));
navigatorKey.currentState.push(route2);
await tester.pump();
final TestGesture gesture = await tester.startGesture(const Offset(0.01, 300));
await tester.pump();
// Move (almost) across the screen, to make the animation as close to finish
// as possible.
await gesture.moveTo(const Offset(800, 200));
await tester.pump();
// image1 should snap to the top left corner of the Row widget.
expect(
tester.getRect(find.byKey(imageKey1, skipOffstage: false)),
rectMoreOrLessEquals(tester.getTopLeft(find.widgetWithText(Row, '1')) & const Size(100, 100), epsilon: 0.01)
);
// Text should respect the correct final size of image1.
expect(
tester.getTopRight(find.byKey(imageKey1, skipOffstage: false)).dx,
moreOrLessEquals(tester.getTopLeft(find.text('1')).dx, epsilon: 0.01)
);
});
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment