Fix: Hero animation for page transition (#164469)

Fix: Hero animation for page transition
fixes: #163989 

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.
This commit is contained in:
Kishan Rathore
2025-04-03 02:57:07 +05:30
committed by GitHub
parent 4503f2f459
commit 9a6ae31e1e
4 changed files with 93 additions and 74 deletions

View File

@@ -100,7 +100,14 @@ mixin MaterialRouteTransitionMixin<T> on PageRoute<T> {
PageTransitionsBuilder? _getPageTransitionBuilder(BuildContext context) {
final TargetPlatform platform = Theme.of(context).platform;
final PageTransitionsTheme pageTransitionsTheme = Theme.of(context).pageTransitionsTheme;
return pageTransitionsTheme.builders[platform];
return pageTransitionsTheme.builders[platform] ??
switch (platform) {
TargetPlatform.iOS || TargetPlatform.macOS => const CupertinoPageTransitionsBuilder(),
TargetPlatform.android ||
TargetPlatform.fuchsia ||
TargetPlatform.windows ||
TargetPlatform.linux => const ZoomPageTransitionsBuilder(),
};
}
// The transitionDuration is used to create the AnimationController which is only

View File

@@ -1077,8 +1077,8 @@ class PageTransitionsTheme with Diagnosticable {
/// Constructs an object that selects a transition based on the platform.
///
/// By default the list of builders is: [ZoomPageTransitionsBuilder]
/// for [TargetPlatform.android], and [CupertinoPageTransitionsBuilder] for
/// [TargetPlatform.iOS] and [TargetPlatform.macOS].
/// for [TargetPlatform.android], [TargetPlatform.windows] and [TargetPlatform.linux]
/// and [CupertinoPageTransitionsBuilder] for [TargetPlatform.iOS] and [TargetPlatform.macOS].
const PageTransitionsTheme({
Map<TargetPlatform, PageTransitionsBuilder> builders = _defaultBuilders,
}) : _builders = builders;
@@ -1088,6 +1088,8 @@ class PageTransitionsTheme with Diagnosticable {
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.windows: ZoomPageTransitionsBuilder(),
TargetPlatform.linux: ZoomPageTransitionsBuilder(),
};
/// The [PageTransitionsBuilder]s supported by this theme.

View File

@@ -22,14 +22,14 @@ void main() {
case TargetPlatform.android:
case TargetPlatform.iOS:
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
expect(
theme.builders[platform],
isNotNull,
reason: 'theme builder for $platform is null',
);
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
expect(
theme.builders[platform],
isNull,

View File

@@ -794,90 +794,100 @@ Future<void> main() async {
expect(find.byKey(secondKey), findsNothing);
});
testWidgets('Hero pop transition interrupted by a push', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
routes: routes,
theme: ThemeData(
pageTransitionsTheme: const PageTransitionsTheme(
builders: <TargetPlatform, PageTransitionsBuilder>{
TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
},
testWidgets(
'Hero pop transition interrupted by a push',
(WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
routes: routes,
theme: ThemeData(
pageTransitionsTheme: const PageTransitionsTheme(
builders: <TargetPlatform, PageTransitionsBuilder>{
TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
},
),
),
),
),
);
);
// Pushes MaterialPageRoute '/two'.
await tester.tap(find.text('two'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
// Pushes MaterialPageRoute '/two'.
await tester.tap(find.text('two'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
// Now the secondKey Card on the '/2' route is visible
expect(find.byKey(secondKey), isOnstage);
expect(find.byKey(secondKey), isInCard);
expect(find.byKey(firstKey), findsNothing);
// Now the secondKey Card on the '/2' route is visible
expect(find.byKey(secondKey), isOnstage);
expect(find.byKey(secondKey), isInCard);
expect(find.byKey(firstKey), findsNothing);
// Pop MaterialPageRoute '/two'.
await tester.tap(find.text('pop'));
// Pop MaterialPageRoute '/two'.
await tester.tap(find.text('pop'));
// Start the flight of Hero 'a' from route '/two' to route '/'. Route '/two'
// is now offstage.
await tester.pump();
// Start the flight of Hero 'a' from route '/two' to route '/'. Route '/two'
// is now offstage.
await tester.pump();
final double initialHeight = tester.getSize(find.byKey(secondKey)).height;
final double finalHeight = tester.getSize(find.byKey(firstKey, skipOffstage: false)).height;
expect(finalHeight, lessThan(initialHeight)); // simplify the checks below
final double initialHeight = tester.getSize(find.byKey(secondKey)).height;
final double finalHeight = tester.getSize(find.byKey(firstKey, skipOffstage: false)).height;
expect(finalHeight, lessThan(initialHeight)); // simplify the checks below
// Build the first hero animation frame in the navigator's overlay.
await tester.pump();
// Build the first hero animation frame in the navigator's overlay.
await tester.pump();
// At this point the hero widgets have been replaced by placeholders
// and the destination hero has been moved to the overlay.
expect(
find.descendant(of: find.byKey(homeRouteKey), matching: find.byKey(firstKey)),
findsNothing,
);
expect(
find.descendant(of: find.byKey(routeTwoKey), matching: find.byKey(secondKey)),
findsNothing,
);
expect(find.byKey(firstKey), isOnstage);
expect(find.byKey(secondKey), findsNothing);
// At this point the hero widgets have been replaced by placeholders
// and the destination hero has been moved to the overlay.
expect(
find.descendant(of: find.byKey(homeRouteKey), matching: find.byKey(firstKey)),
findsNothing,
);
expect(
find.descendant(of: find.byKey(routeTwoKey), matching: find.byKey(secondKey)),
findsNothing,
);
expect(find.byKey(firstKey), isOnstage);
expect(find.byKey(secondKey), findsNothing);
// The duration of a MaterialPageRoute's transition is 300ms.
// At 150ms Hero 'a' is mid-flight.
await tester.pump(const Duration(milliseconds: 150));
final double height150ms = tester.getSize(find.byKey(firstKey)).height;
expect(height150ms, lessThan(initialHeight));
expect(height150ms, greaterThan(finalHeight));
// The duration of a MaterialPageRoute's transition is 300ms.
// At 150ms Hero 'a' is mid-flight.
await tester.pump(const Duration(milliseconds: 150));
final double height150ms = tester.getSize(find.byKey(firstKey)).height;
expect(height150ms, lessThan(initialHeight));
expect(height150ms, greaterThan(finalHeight));
// Push route '/two' before the pop transition from '/two' has finished.
await tester.tap(find.text('two'));
// Push route '/two' before the pop transition from '/two' has finished.
await tester.tap(find.text('two'));
// Restart the flight of Hero 'a'. Now it's flying from route '/' to
// route '/two'.
await tester.pump();
// Restart the flight of Hero 'a'. Now it's flying from route '/' to
// route '/two'.
await tester.pump();
// After flying in the opposite direction for 50ms Hero 'a' will
// be smaller than it was, but bigger than its initial size.
await tester.pump(const Duration(milliseconds: 50));
final double height200ms = tester.getSize(find.byKey(firstKey)).height;
expect(height200ms, greaterThan(height150ms));
expect(finalHeight, lessThan(height200ms));
// After flying in the opposite direction for 50ms Hero 'a' will
// be smaller than it was, but bigger than its initial size.
await tester.pump(const Duration(milliseconds: 50));
final double height200ms = tester.getSize(find.byKey(firstKey)).height;
expect(height200ms, greaterThan(height150ms));
expect(finalHeight, lessThan(height200ms));
// Hero a's return flight at 149ms. The outgoing (push) flight took
// 150ms so we should be just about back to where Hero 'a' started.
const double epsilon = 0.001;
await tester.pump(const Duration(milliseconds: 99));
moreOrLessEquals(tester.getSize(find.byKey(firstKey)).height - initialHeight, epsilon: epsilon);
// Hero a's return flight at 149ms. The outgoing (push) flight took
// 150ms so we should be just about back to where Hero 'a' started.
const double epsilon = 0.001;
await tester.pump(const Duration(milliseconds: 99));
moreOrLessEquals(
tester.getSize(find.byKey(firstKey)).height - initialHeight,
epsilon: epsilon,
);
// The flight is finished. We're back to where we started.
await tester.pump(const Duration(milliseconds: 300));
expect(find.byKey(secondKey), isOnstage);
expect(find.byKey(secondKey), isInCard);
expect(find.byKey(firstKey), findsNothing);
});
// The flight is finished. We're back to where we started.
await tester.pump(const Duration(milliseconds: 300));
expect(find.byKey(secondKey), isOnstage);
expect(find.byKey(secondKey), isInCard);
expect(find.byKey(firstKey), findsNothing);
},
variant: const TargetPlatformVariant(<TargetPlatform>{
TargetPlatform.android,
TargetPlatform.linux,
}),
);
testWidgets('Destination hero disappears mid-flight', (WidgetTester tester) async {
const Key homeHeroKey = Key('home hero');