Hero Animations with Multiple Widgets in Flutter
Flutter's Hero widgets provide a powerful way to create visually stunning shared element transitions between routes. When a user navigates from one screen to another, a Hero animation can smoothly animate a widget from its position and size on the source screen to its new position and size on the destination screen, creating a sense of visual continuity. While basic Hero animations are straightforward for single widgets, the challenge often arises when you need to animate a complex UI component comprising multiple widgets.
Introduction to Hero Animations
At its core, a Hero animation is about making a widget appear to "fly" from one screen to another. This is achieved by wrapping the same widget on both the source and destination screens with a Hero widget and assigning them identical tag properties. Flutter then takes care of the interpolation, scaling, and positioning during the route transition.
The Hero widget identifies a widget that should fly between routes. It takes two main properties:
tag: A unique identifier for the Hero. The Hero on the source route and the Hero on the destination route must have the same tag.child: The widget that will be animated.
The Basic Hero Animation
Let's start with a simple example of a single image animating between two screens.
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Basic Hero Demo',
theme: ThemeData(primarySwatch: Colors.blue),
home: SourceScreen(),
);
}
}
class SourceScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Source Screen')),
body: Center(
child: GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => DestinationScreen()),
);
},
child: Hero(
tag: 'my-image-hero',
child: Image.network(
'https://picsum.photos/id/100/100/75',
width: 100,
height: 75,
fit: BoxFit.cover,
),
),
),
),
);
}
}
class DestinationScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Destination Screen')),
body: Center(
child: Hero(
tag: 'my-image-hero',
child: Image.network(
'https://picsum.photos/id/100/400/300', // Larger size
width: 400,
height: 300,
fit: BoxFit.cover,
),
),
),
);
}
}
In this example, when you tap the small image on SourceScreen, it seamlessly grows and moves to the center of DestinationScreen, demonstrating a basic Hero animation.
The Challenge: Animating Multiple Widgets
The challenge arises when your "shared element" is not a single widget but a composition of several widgets, like a product card containing an image, a title, a price, and a description. You might want this entire card to animate, or perhaps specific elements within it (like the image and title) to animate independently to different positions on the next screen.
Directly wrapping a Column or Row of distinct visual elements in a single Hero tag will make the entire group animate as one cohesive unit. While this is often desired, it limits the flexibility of animating individual sub-components to different final states or positions.
Approach 1: Grouping Widgets as a Single Hero
The simplest method to animate multiple widgets is to wrap their common parent widget (e.g., a Card, Container, Column, or Row) within a single Hero widget. This makes the entire group of widgets animate as a single visual entity. Flutter will interpolate the size, position, and any other properties of the entire parent widget.
Pros:
- Easy to implement.
- Maintains the layout and relative positioning of child widgets during the transition.
Cons:
- Lacks individual control over how child widgets animate. The group scales and moves uniformly.
- If the internal structure or arrangement of widgets changes drastically between screens, the animation might look less natural as the entire block interpolates.
Example: Animating a Card with Image and Text
Here, a Card containing an Image, Text for title, and another Text for description is wrapped in a single Hero.
// main.dart (assuming MyApp structure as above)
class SourceScreenGrouped extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Source Screen (Grouped Hero)')),
body: Center(
child: GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => DestinationScreenGrouped()),
);
},
child: Hero(
tag: 'my-combined-card-hero',
child: Card(
elevation: 5,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.network(
'https://picsum.photos/id/237/200/150',
width: 200,
height: 150,
fit: BoxFit.cover,
),
SizedBox(height: 8),
Text(
'Cute Doggo',
style: Theme.of(context).textTheme.headline6,
),
Text(
'A very cute puppy image!',
style: Theme.of(context).textTheme.subtitle1,
),
],
),
),
),
),
),
),
);
}
}
class DestinationScreenGrouped extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Destination Screen (Grouped Hero)')),
body: Center(
child: Hero(
tag: 'my-combined-card-hero',
child: Card( // The destination widget must also be a Card with similar structure
elevation: 5,
child: Padding(
padding: const EdgeInsets.all(16.0), // Padding can be different, Hero interpolates
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.network(
'https://picsum.photos/id/237/400/300', // Different size for interpolation
width: 400,
height: 300,
fit: BoxFit.cover,
),
SizedBox(height: 16), // Different spacing
Text(
'Cute Doggo (Expanded View)',
style: Theme.of(context).textTheme.headline4, // Different style
),
Text(
'This is an expanded view of the very cute puppy image! Enjoy its cuteness and details.',
style: Theme.of(context).textTheme.bodyText1,
textAlign: TextAlign.center,
),
],
),
),
),
),
),
);
}
}
When tapping the card on the source screen, the entire card (image and text together) will animate and expand to the center of the destination screen. The internal padding, spacing, and text styles will smoothly interpolate between the two states.
Approach 2: Compound Hero Animations with Multiple Hero Widgets
For greater control, or when different parts of your UI need to transition to distinct final states or positions on the destination screen, you can employ multiple Hero widgets. Each distinct part of your UI (e.g., an image, a title text, a rating star) will be wrapped in its own Hero widget with a unique tag.
On the destination screen, you will then have corresponding Hero widgets with matching tags. These can be arranged using `Column`, `Row`, `Stack`, or any other layout widget to achieve the desired final composition, allowing them to animate to independent positions.
Pros:
- Granular control over each animating element.
- Allows for complex and visually rich transitions where elements seem to "disassemble" from the source and "reassemble" at different positions/styles on the destination.
- Ideal when the destination layout for sub-elements is significantly different from the source layout.
Cons:
- More complex to implement.
- Requires careful tag management to ensure each source Hero has a unique matching destination Hero.
- Layout on the destination screen needs to be precisely managed to ensure elements land correctly after their independent animations.
Example: Animating Image and Text Separately
In this scenario, the image and the text will have their own Hero tags and will animate independently. Notice how the destination screen places them differently.
// main.dart (assuming MyApp structure as above)
class SourceScreenMultiple extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Source Screen (Multiple Hero)')),
body: Center(
child: GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => DestinationScreenMultiple()),
);
},
child: Column( // A simple Column for the source
mainAxisSize: MainAxisSize.min,
children: [
Hero(
tag: 'hero_image_tag',
child: Image.network(
'https://picsum.photos/id/101/100/75', // Different image ID for uniqueness
width: 100,
height: 75,
fit: BoxFit.cover,
),
),
SizedBox(height: 8),
Hero(
tag: 'hero_title_tag',
child: Text(
'Forest Path Title',
style: Theme.of(context).textTheme.subtitle1,
),
),
],
),
),
),
);
}
}
class DestinationScreenMultiple extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Destination Screen (Multiple Hero)')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column( // Arranging elements differently on destination
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Hero(
tag: 'hero_image_tag',
child: Image.network(
'https://picsum.photos/id/101/400/300',
width: 400,
height: 300,
fit: BoxFit.cover,
),
),
SizedBox(height: 16),
Hero(
tag: 'hero_title_tag',
child: Text(
'Grand Forest Path (Expanded View)',
style: Theme.of(context).textTheme.headline4,
),
),
SizedBox(height: 8),
Text(
'A beautiful description of the scenic forest path, inviting viewers to explore its serene beauty and tranquility.',
style: Theme.of(context).textTheme.bodyText1,
),
],
),
),
);
}
}
In this example, when the user taps the combined `Column` on SourceScreenMultiple, the image will fly to its larger position at the top of DestinationScreenMultiple, and the title text will simultaneously fly to its new, larger position below the image. They animate as distinct entities, providing a more dynamic and intricate transition.
Conclusion
Flutter's Hero animations are a powerful tool for creating engaging user experiences. Whether you need to animate a single widget, a group of widgets as one unit, or individual components of a complex UI to distinct destinations, the Hero widget provides the flexibility. By understanding the two main approaches—grouping widgets under a single Hero tag or using multiple Hero widgets for compound animations—you can craft sophisticated and visually appealing transitions in your Flutter applications.