Flutter Lesson 56 of 83 4 min read
Hero Animations and Page Transitions in Flutter
Animate between screens in Flutter: shared element transitions with Hero, and custom fade, slide and scale routes with PageRouteBuilder.
On this page
Moving between screens is where animation helps most. It shows where the new screen came from and how to get back.
Hero: one element flies to the next screen #
Wrap the same visual element on both screens in a Hero with the same tag. Flutter animates it from its old position and size to the new one.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: GalleryPage()));
class GalleryPage extends StatelessWidget {
const GalleryPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Gallery')),
body: GridView.count(
crossAxisCount: 3,
padding: const EdgeInsets.all(8),
mainAxisSpacing: 8,
crossAxisSpacing: 8,
children: [
for (var i = 0; i < 9; i++)
GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute<void>(builder: (_) => PhotoPage(index: i)),
),
child: Hero(
tag: 'photo-$i',
child: ColoredBox(
color: Colors.primaries[i % Colors.primaries.length],
),
),
),
],
),
);
}
}
class PhotoPage extends StatelessWidget {
const PhotoPage({super.key, required this.index});
final int index;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Photo $index')),
body: Center(
child: Hero(
tag: 'photo-$index',
child: AspectRatio(
aspectRatio: 1,
child: ColoredBox(
color: Colors.primaries[index % Colors.primaries.length],
),
),
),
),
);
}
}
Tap a square and it grows into place on the next screen. Go back and it returns.
Hero rules #
- The tag must be identical on both screens and unique within each screen. In a list, include the item’s id in the tag.
- The two children should look alike. A hero morphing between unrelated widgets looks wrong.
- Two heroes with the same tag on one screen cause an error. A common trap: several
FloatingActionButtons, which are heroes by default. Give each a differentheroTag. - With images, use the same
fiton both sides.
Text inside a Hero #
Text can flicker during the flight because it leaves its theme behind. Wrap it in a Material with transparency:
Hero(
tag: 'title-$id',
child: Material(
type: MaterialType.transparency,
child: Text(title, style: Theme.of(context).textTheme.titleLarge),
),
)
Custom page transitions #
MaterialPageRoute uses the platform’s standard transition. PageRouteBuilder lets you write your own. It hands you an animation running from 0 to 1 as the page enters.
Route<T> fadeRoute<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
);
}
// Usage
Navigator.push(context, fadeRoute<void>(const DetailsPage()));
Slide up, and scale #
Route<T> slideUpRoute<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final offset = Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero)
.animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic));
return SlideTransition(position: offset, child: child);
},
);
}
Route<T> scaleRoute<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOut);
return FadeTransition(
opacity: curved,
child: ScaleTransition(
scale: Tween<double>(begin: 0.92, end: 1).animate(curved),
child: child,
),
);
},
);
}
secondaryAnimation runs when another page is pushed on top of this one. Use it to move the page underneath, for example sliding it slightly to the left.
One transition for the whole app #
Set it in the theme, per platform.
ThemeData(
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: FadeForwardsPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
},
),
)
Keep the iOS builder for iOS. It supports the swipe-back gesture that iPhone users expect, which a custom transition does not.
With go_router #
GoRoute(
path: '/details',
pageBuilder: (context, state) => CustomTransitionPage<void>(
key: state.pageKey,
child: const DetailsPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
)
Material motion #
The animations package provides the standard Material transitions: container transform (a card that expands into a page), shared axis, fade through and fade. OpenContainer in particular gives a polished effect with a few lines.
Animating items inside the new page #
Run a controller in initState to bring content in once the page has arrived, as shown under staggered animations in the previous lesson.
Guidelines #
- Be consistent. The same kind of navigation should always use the same transition.
- Let the direction mean something: forward comes from the right or below, back returns the same way.
- Keep page transitions around 300 milliseconds.
- Do not replace platform transitions without a reason. Familiar motion is comfortable motion.
Try it yourself #
Build a product list where each row has a small image. Tapping a row opens a details page with a large image at the top, connected by a Hero. Open a separate “filters” page from the app bar with a slide-up transition.