Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Implement Flutter animations: implicit, explicit, hero, staggered, and physics-based with a decision tree guide.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
references/hero.md
1# Hero Animations Reference23Hero animations create shared element transitions between screens, making elements appear to "fly" from one route to another.45## Core Concept67Use two `Hero` widgets with matching `tag` in different routes. Flutter automatically animates the transition between them.89## Basic Hero Animation1011### Simple Image Transition1213**Source route (list screen):**14```dart15GestureDetector(16onTap: () {17Navigator.of(context).push(18MaterialPageRoute<void>(19builder: (context) => const DetailScreen(),20),21);22},23child: Hero(24tag: 'hero-image',25child: Image.asset('images/thumbnail.png'),26),27)28```2930**Destination route (detail screen):**31```dart32Scaffold(33appBar: AppBar(title: const Text('Detail')),34body: GestureDetector(35onTap: () => Navigator.of(context).pop(),36child: Hero(37tag: 'hero-image', // Same tag!38child: Image.asset('images/thumbnail.png'),39),40),41)42```4344### Custom PhotoHero Widget4546Reusable hero widget for images:4748```dart49class PhotoHero extends StatelessWidget {50const PhotoHero({51super.key,52required this.photo,53this.onTap,54required this.width,55});5657final String photo;58final VoidCallback? onTap;59final double width;6061@override62Widget build(BuildContext context) {63return SizedBox(64width: width,65child: Hero(66tag: photo,67child: Material(68color: Colors.transparent,69child: InkWell(70onTap: onTap,71child: Image.asset(72photo,73fit: BoxFit.contain,74),75),76),77),78);79}80}81```8283**Usage in source route:**84```dart85class SourceScreen extends StatelessWidget {86const SourceScreen({super.key});8788@override89Widget build(BuildContext context) {90return Scaffold(91appBar: AppBar(title: const Text('Gallery')),92body: GridView.builder(93gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(94crossAxisCount: 2,95),96itemCount: 10,97itemBuilder: (context, index) {98return PhotoHero(99photo: 'images/photo_$index.png',100width: 100,101onTap: () {102Navigator.of(context).push(103MaterialPageRoute<void>(104builder: (context) => DetailScreen(photo: 'images/photo_$index.png'),105),106);107},108);109},110),111);112}113}114```115116**Usage in destination route:**117```dart118class DetailScreen extends StatelessWidget {119const DetailScreen({super.key, required this.photo});120121final String photo;122123@override124Widget build(BuildContext context) {125return Scaffold(126appBar: AppBar(title: const Text('Detail')),127body: Center(128child: PhotoHero(129photo: photo,130width: 300,131onTap: () => Navigator.of(context).pop(),132),133),134);135}136}137```138139## Hero Tag Best Practices140141### Using Object as Tag142143For unique, consistent tags:144145```dart146// Data model147class Photo {148final String url;149final String id;150151Photo({required this.url, required this.id});152}153154// Source155Hero(156tag: photo.id, // Use unique identifier157child: Image.network(photo.url),158)159160// Destination161Hero(162tag: photo.id, // Same unique identifier163child: Image.network(photo.url),164)165```166167### Using Data Object as Tag168169When data object is consistent:170171```dart172// Source173Hero(174tag: photo, // Photo object must be same instance or implement ==175child: Image.network(photo.url),176)177178// Destination179Hero(180tag: photo, // Same Photo object181child: Image.network(photo.url),182)183```184185**Important:** If using object as tag, ensure proper `==` and `hashCode` implementation.186187## Custom Hero Flight Path188189### MaterialRectArcTween (Default)190191```dart192Hero(193tag: 'hero-image',194flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {195return AnimatedBuilder(196animation: animation,197builder: (context, child) {198return Transform.scale(199scale: animation.value,200child: child,201);202},203child: child,204);205},206createRectTween: (begin, end) {207return MaterialRectArcTween(begin: begin, end: end);208},209child: Image.asset('image.png'),210)211```212213### MaterialRectCenterArcTween214215Center-based interpolation (good for maintaining aspect ratio):216217```dart218static RectTween _createRectTween(Rect? begin, Rect? end) {219return MaterialRectCenterArcTween(begin: begin, end: end);220}221222Hero(223tag: 'hero-image',224createRectTween: _createRectTween,225child: Image.asset('image.png'),226)227```228229### Custom RectTween230231For complete control:232233```dart234class LinearRectTween extends Tween<Rect> {235LinearRectTween({required Rect begin, required Rect end})236: super(begin: begin, end: end);237238@override239Rect lerp(double t) => Rect.lerp(begin!, end!, t);240}241242Hero(243tag: 'hero-image',244createRectTween: (begin, end) => LinearRectTween(begin: begin, end: end),245child: Image.asset('image.png'),246)247```248249## Radial Hero Animation250251Transform from circle to rectangle during transition.252253### RadialExpansion Widget254255```dart256import 'dart:math' as math;257258class RadialExpansion extends StatelessWidget {259const RadialExpansion({260super.key,261required this.maxRadius,262this.child,263}) : clipRectSize = 2.0 * (maxRadius / math.sqrt2);264265final double maxRadius;266final double clipRectSize;267final Widget? child;268269@override270Widget build(BuildContext context) {271return ClipOval(272child: Center(273child: SizedBox(274width: clipRectSize,275height: clipRectSize,276child: ClipRect(child: child),277),278),279);280}281}282```283284### Radial Photo Widget285286```dart287class RadialPhoto extends StatelessWidget {288const RadialPhoto({289super.key,290required this.photo,291this.onTap,292});293294final String photo;295final VoidCallback? onTap;296297@override298Widget build(BuildContext context) {299return Material(300color: Theme.of(context).primaryColor.withValues(alpha: 0.25),301child: InkWell(302onTap: onTap,303child: Image.asset(304photo,305fit: BoxFit.contain,306),307),308);309}310}311```312313### Complete Radial Hero Example314315```dart316class RadialHeroAnimation extends StatelessWidget {317const RadialHeroAnimation({super.key});318319@override320Widget build(BuildContext context) {321return Scaffold(322appBar: AppBar(title: const Text('Radial Hero')),323body: ListView(324children: List.generate(6, (index) {325final photo = 'images/photo_$index.png';326return Hero(327tag: photo,328createRectTween: _createRectTween,329flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {330return AnimatedBuilder(331animation: animation,332builder: (context, child) {333return FadeTransition(334opacity: animation,335child: child,336);337},338child: child,339);340},341child: RadialExpansion(342maxRadius: 120,343child: RadialPhoto(344photo: photo,345onTap: () {346Navigator.of(context).push(347MaterialPageRoute<void>(348builder: (context) => DetailScreen(photo: photo),349),350);351},352),353),354);355}),356),357);358}359360static RectTween _createRectTween(Rect? begin, Rect? end) {361return MaterialRectCenterArcTween(begin: begin, end: end);362}363}364365class DetailScreen extends StatelessWidget {366const DetailScreen({super.key, required this.photo});367368final String photo;369370@override371Widget build(BuildContext context) {372return Scaffold(373appBar: AppBar(title: const Text('Detail')),374body: Center(375child: Hero(376tag: photo,377createRectTween: RadialHeroAnimation._createRectTween,378child: SizedBox(379width: 300,380height: 300,381child: RadialPhoto(382photo: photo,383onTap: () => Navigator.of(context).pop(),384),385),386),387),388);389}390}391```392393## Custom Placeholder During Flight394395### flightShuttleBuilder396397Customize hero appearance during flight:398399```dart400Hero(401tag: 'hero-image',402flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {403// fromContext - source hero's context404// toContext - destination hero's context405// direction - HeroFlightDirection.push or pop406// animation - Animation<double> for the flight407408return AnimatedBuilder(409animation: animation,410builder: (context, child) {411return Transform.rotate(412angle: animation.value * math.pi,413child: child,414);415},416child: child,417);418},419child: Image.asset('image.png'),420)421```422423### Replace Entire Hero During Flight424425```dart426Hero(427tag: 'hero-image',428flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {429// Show different widget during flight430return Container(431width: 100,432height: 100,433color: Colors.blue,434);435},436child: Image.asset('image.png'),437)438```439440## Transition Settings441442### Animation Duration443444```dart445MaterialApp(446// Set global hero animation duration447theme: ThemeData(448pageTransitionsTheme: PageTransitionsTheme(449builders: {450TargetPlatform.android: ZoomPageTransitionsBuilder(),451TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),452},453),454),455)456```457458### Custom PageTransitionBuilder459460```dart461class CustomHeroTransitionBuilder extends PageTransitionsBuilder {462@override463Widget buildTransitions<T>(464PageRoute<T> route,465BuildContext? secondaryContext,466Widget child,467) {468return FadeUpwardsPageTransitionsBuilder().buildTransitions(469route,470secondaryContext,471child,472);473}474}475```476477## Hero Mode478479### Disable Hero Animations480481```dart482HeroMode(483enabled: false, // Disable all child hero animations484child: ListView(485children: [486Hero(tag: 'image1', child: Image.asset('1.png')),487Hero(tag: 'image2', child: Image.asset('2.png')),488],489),490)491```492493### Conditional Hero Mode494495```dart496HeroMode(497enabled: !_disableAnimations,498child: Hero(tag: 'image', child: Image.asset('image.png')),499)500```501502## Debugging Hero Animations503504### Slow Animation505506```dart507void main() {508timeDilation = 10.0; // 10x slower509runApp(MyApp());510}511```512513### Visualize Hero Bounds514515```dart516void main() {517debugPaintSizeEnabled = true;518runApp(MyApp());519}520```521522### Print Hero Tags523524```dart525class DebugHero extends StatelessWidget {526const DebugHero({527super.key,528required this.tag,529required this.child,530});531532final Object tag;533final Widget child;534535@override536Widget build(BuildContext context) {537print('Building Hero with tag: $tag');538return Hero(tag: tag, child: child);539}540}541```542543## Best Practices544545### DO546547- Use unique, consistent tags (often the data object itself)548- Keep hero widget trees similar between routes549- Wrap images in `Material` with transparent color for "pop" effect550- Use `timeDilation` to debug transitions551- Consider `createRectTween` for custom flight paths552- Use `flightShuttleBuilder` for custom flight appearance553554### DON'T555556- Use duplicate tags (conflicts!)557- Change hero structure significantly between routes (jarring transition)558- Forget `Material` wrapper (no splash effect)559- Use very large images in heroes (performance)560- Ignore aspect ratio during transition (distortion)561562## Common Patterns563564### Grid to Fullscreen Image565566**Grid item:**567```dart568PhotoHero(569photo: photo.url,570width: 150, // Smaller in grid571onTap: () => Navigator.push(..., detailScreen),572)573```574575**Fullscreen:**576```dart577PhotoHero(578photo: photo.url,579width: MediaQuery.of(context).size.width, // Full width580onTap: () => Navigator.pop(),581)582```583584### List Header to Page Header585586**List:**587```dart588Hero(589tag: 'header',590child: SizedBox(591height: 200,592child: Image.asset('header.jpg'),593),594)595```596597**Page:**598```dart599Hero(600tag: 'header',601child: SizedBox(602height: 300, // Larger on detail page603child: Image.asset('header.jpg'),604),605)606```607608### Shared Element with Content Update609610```dart611// In both routes612Hero(613tag: 'card',614child: Card(615child: Column(616children: [617Image.asset('image.png'),618Text(showDetails ? 'Full description...' : 'Brief description'),619],620),621),622)623```624625## Performance Tips626627- Optimize hero images (compress, lazy load)628- Use `RepaintBoundary` around hero children if needed629- Test on low-end devices630- Profile with Flutter DevTools Performance overlay631- Avoid complex widget trees inside hero632633## Accessibility634635- Respect `MediaQuery.disableAnimations` setting636- Consider alternative navigation for users who prefer no animations637- Ensure hero content remains accessible during transition638- Test with screen readers639640## Advanced Techniques641642### Multiple Heroes on Same Route643644```dart645class PhotoDetail extends StatelessWidget {646const PhotoDetail({super.key, required this.photos});647648final List<String> photos;649650@override651Widget build(BuildContext context) {652return Stack(653children: [654// Main photo hero655Positioned.fill(656child: Hero(657tag: photos[0],658child: Image.asset(photos[0]),659),660),661// Overlay hero (e.g., like button)662Positioned(663top: 16,664right: 16,665child: Hero(666tag: 'like-button',667child: Icon(Icons.favorite),668),669),670],671);672}673}674```675676### Nested Heroes677678```dart679Hero(680tag: 'parent',681child: Card(682child: Column(683children: [684Hero(685tag: 'child-image',686child: Image.asset('image.png'),687),688Hero(689tag: 'child-title',690child: Text('Title'),691),692],693),694),695)696```697698### Hero with Scroll Views699700```dart701class ScrollableHero extends StatelessWidget {702@override703Widget build(BuildContext context) {704return CustomScrollView(705slivers: [706SliverAppBar(707expandedHeight: 300,708flexibleSpace: FlexibleSpaceBar(709background: Hero(710tag: 'header-image',711child: Image.asset('header.jpg', fit: BoxFit.cover),712),713),714),715SliverList(716delegate: SliverChildListDelegate([717// Content718]),719),720],721);722}723}724```725