725 lines
14 KiB
Markdown
725 lines
14 KiB
Markdown
# Hero Animations Reference
|
|
|
|
Hero animations create shared element transitions between screens, making elements appear to "fly" from one route to another.
|
|
|
|
## Core Concept
|
|
|
|
Use two `Hero` widgets with matching `tag` in different routes. Flutter automatically animates the transition between them.
|
|
|
|
## Basic Hero Animation
|
|
|
|
### Simple Image Transition
|
|
|
|
**Source route (list screen):**
|
|
```dart
|
|
GestureDetector(
|
|
onTap: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (context) => const DetailScreen(),
|
|
),
|
|
);
|
|
},
|
|
child: Hero(
|
|
tag: 'hero-image',
|
|
child: Image.asset('images/thumbnail.png'),
|
|
),
|
|
)
|
|
```
|
|
|
|
**Destination route (detail screen):**
|
|
```dart
|
|
Scaffold(
|
|
appBar: AppBar(title: const Text('Detail')),
|
|
body: GestureDetector(
|
|
onTap: () => Navigator.of(context).pop(),
|
|
child: Hero(
|
|
tag: 'hero-image', // Same tag!
|
|
child: Image.asset('images/thumbnail.png'),
|
|
),
|
|
),
|
|
)
|
|
```
|
|
|
|
### Custom PhotoHero Widget
|
|
|
|
Reusable hero widget for images:
|
|
|
|
```dart
|
|
class PhotoHero extends StatelessWidget {
|
|
const PhotoHero({
|
|
super.key,
|
|
required this.photo,
|
|
this.onTap,
|
|
required this.width,
|
|
});
|
|
|
|
final String photo;
|
|
final VoidCallback? onTap;
|
|
final double width;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
width: width,
|
|
child: Hero(
|
|
tag: photo,
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
child: Image.asset(
|
|
photo,
|
|
fit: BoxFit.contain,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Usage in source route:**
|
|
```dart
|
|
class SourceScreen extends StatelessWidget {
|
|
const SourceScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Gallery')),
|
|
body: GridView.builder(
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 2,
|
|
),
|
|
itemCount: 10,
|
|
itemBuilder: (context, index) {
|
|
return PhotoHero(
|
|
photo: 'images/photo_$index.png',
|
|
width: 100,
|
|
onTap: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (context) => DetailScreen(photo: 'images/photo_$index.png'),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Usage in destination route:**
|
|
```dart
|
|
class DetailScreen extends StatelessWidget {
|
|
const DetailScreen({super.key, required this.photo});
|
|
|
|
final String photo;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Detail')),
|
|
body: Center(
|
|
child: PhotoHero(
|
|
photo: photo,
|
|
width: 300,
|
|
onTap: () => Navigator.of(context).pop(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Hero Tag Best Practices
|
|
|
|
### Using Object as Tag
|
|
|
|
For unique, consistent tags:
|
|
|
|
```dart
|
|
// Data model
|
|
class Photo {
|
|
final String url;
|
|
final String id;
|
|
|
|
Photo({required this.url, required this.id});
|
|
}
|
|
|
|
// Source
|
|
Hero(
|
|
tag: photo.id, // Use unique identifier
|
|
child: Image.network(photo.url),
|
|
)
|
|
|
|
// Destination
|
|
Hero(
|
|
tag: photo.id, // Same unique identifier
|
|
child: Image.network(photo.url),
|
|
)
|
|
```
|
|
|
|
### Using Data Object as Tag
|
|
|
|
When data object is consistent:
|
|
|
|
```dart
|
|
// Source
|
|
Hero(
|
|
tag: photo, // Photo object must be same instance or implement ==
|
|
child: Image.network(photo.url),
|
|
)
|
|
|
|
// Destination
|
|
Hero(
|
|
tag: photo, // Same Photo object
|
|
child: Image.network(photo.url),
|
|
)
|
|
```
|
|
|
|
**Important:** If using object as tag, ensure proper `==` and `hashCode` implementation.
|
|
|
|
## Custom Hero Flight Path
|
|
|
|
### MaterialRectArcTween (Default)
|
|
|
|
```dart
|
|
Hero(
|
|
tag: 'hero-image',
|
|
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
|
|
return AnimatedBuilder(
|
|
animation: animation,
|
|
builder: (context, child) {
|
|
return Transform.scale(
|
|
scale: animation.value,
|
|
child: child,
|
|
);
|
|
},
|
|
child: child,
|
|
);
|
|
},
|
|
createRectTween: (begin, end) {
|
|
return MaterialRectArcTween(begin: begin, end: end);
|
|
},
|
|
child: Image.asset('image.png'),
|
|
)
|
|
```
|
|
|
|
### MaterialRectCenterArcTween
|
|
|
|
Center-based interpolation (good for maintaining aspect ratio):
|
|
|
|
```dart
|
|
static RectTween _createRectTween(Rect? begin, Rect? end) {
|
|
return MaterialRectCenterArcTween(begin: begin, end: end);
|
|
}
|
|
|
|
Hero(
|
|
tag: 'hero-image',
|
|
createRectTween: _createRectTween,
|
|
child: Image.asset('image.png'),
|
|
)
|
|
```
|
|
|
|
### Custom RectTween
|
|
|
|
For complete control:
|
|
|
|
```dart
|
|
class LinearRectTween extends Tween<Rect> {
|
|
LinearRectTween({required Rect begin, required Rect end})
|
|
: super(begin: begin, end: end);
|
|
|
|
@override
|
|
Rect lerp(double t) => Rect.lerp(begin!, end!, t);
|
|
}
|
|
|
|
Hero(
|
|
tag: 'hero-image',
|
|
createRectTween: (begin, end) => LinearRectTween(begin: begin, end: end),
|
|
child: Image.asset('image.png'),
|
|
)
|
|
```
|
|
|
|
## Radial Hero Animation
|
|
|
|
Transform from circle to rectangle during transition.
|
|
|
|
### RadialExpansion Widget
|
|
|
|
```dart
|
|
import 'dart:math' as math;
|
|
|
|
class RadialExpansion extends StatelessWidget {
|
|
const RadialExpansion({
|
|
super.key,
|
|
required this.maxRadius,
|
|
this.child,
|
|
}) : clipRectSize = 2.0 * (maxRadius / math.sqrt2);
|
|
|
|
final double maxRadius;
|
|
final double clipRectSize;
|
|
final Widget? child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ClipOval(
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: clipRectSize,
|
|
height: clipRectSize,
|
|
child: ClipRect(child: child),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Radial Photo Widget
|
|
|
|
```dart
|
|
class RadialPhoto extends StatelessWidget {
|
|
const RadialPhoto({
|
|
super.key,
|
|
required this.photo,
|
|
this.onTap,
|
|
});
|
|
|
|
final String photo;
|
|
final VoidCallback? onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: Theme.of(context).primaryColor.withValues(alpha: 0.25),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
child: Image.asset(
|
|
photo,
|
|
fit: BoxFit.contain,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Complete Radial Hero Example
|
|
|
|
```dart
|
|
class RadialHeroAnimation extends StatelessWidget {
|
|
const RadialHeroAnimation({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Radial Hero')),
|
|
body: ListView(
|
|
children: List.generate(6, (index) {
|
|
final photo = 'images/photo_$index.png';
|
|
return Hero(
|
|
tag: photo,
|
|
createRectTween: _createRectTween,
|
|
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
|
|
return AnimatedBuilder(
|
|
animation: animation,
|
|
builder: (context, child) {
|
|
return FadeTransition(
|
|
opacity: animation,
|
|
child: child,
|
|
);
|
|
},
|
|
child: child,
|
|
);
|
|
},
|
|
child: RadialExpansion(
|
|
maxRadius: 120,
|
|
child: RadialPhoto(
|
|
photo: photo,
|
|
onTap: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (context) => DetailScreen(photo: photo),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
static RectTween _createRectTween(Rect? begin, Rect? end) {
|
|
return MaterialRectCenterArcTween(begin: begin, end: end);
|
|
}
|
|
}
|
|
|
|
class DetailScreen extends StatelessWidget {
|
|
const DetailScreen({super.key, required this.photo});
|
|
|
|
final String photo;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Detail')),
|
|
body: Center(
|
|
child: Hero(
|
|
tag: photo,
|
|
createRectTween: RadialHeroAnimation._createRectTween,
|
|
child: SizedBox(
|
|
width: 300,
|
|
height: 300,
|
|
child: RadialPhoto(
|
|
photo: photo,
|
|
onTap: () => Navigator.of(context).pop(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Custom Placeholder During Flight
|
|
|
|
### flightShuttleBuilder
|
|
|
|
Customize hero appearance during flight:
|
|
|
|
```dart
|
|
Hero(
|
|
tag: 'hero-image',
|
|
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
|
|
// fromContext - source hero's context
|
|
// toContext - destination hero's context
|
|
// direction - HeroFlightDirection.push or pop
|
|
// animation - Animation<double> for the flight
|
|
|
|
return AnimatedBuilder(
|
|
animation: animation,
|
|
builder: (context, child) {
|
|
return Transform.rotate(
|
|
angle: animation.value * math.pi,
|
|
child: child,
|
|
);
|
|
},
|
|
child: child,
|
|
);
|
|
},
|
|
child: Image.asset('image.png'),
|
|
)
|
|
```
|
|
|
|
### Replace Entire Hero During Flight
|
|
|
|
```dart
|
|
Hero(
|
|
tag: 'hero-image',
|
|
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
|
|
// Show different widget during flight
|
|
return Container(
|
|
width: 100,
|
|
height: 100,
|
|
color: Colors.blue,
|
|
);
|
|
},
|
|
child: Image.asset('image.png'),
|
|
)
|
|
```
|
|
|
|
## Transition Settings
|
|
|
|
### Animation Duration
|
|
|
|
```dart
|
|
MaterialApp(
|
|
// Set global hero animation duration
|
|
theme: ThemeData(
|
|
pageTransitionsTheme: PageTransitionsTheme(
|
|
builders: {
|
|
TargetPlatform.android: ZoomPageTransitionsBuilder(),
|
|
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
|
},
|
|
),
|
|
),
|
|
)
|
|
```
|
|
|
|
### Custom PageTransitionBuilder
|
|
|
|
```dart
|
|
class CustomHeroTransitionBuilder extends PageTransitionsBuilder {
|
|
@override
|
|
Widget buildTransitions<T>(
|
|
PageRoute<T> route,
|
|
BuildContext? secondaryContext,
|
|
Widget child,
|
|
) {
|
|
return FadeUpwardsPageTransitionsBuilder().buildTransitions(
|
|
route,
|
|
secondaryContext,
|
|
child,
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Hero Mode
|
|
|
|
### Disable Hero Animations
|
|
|
|
```dart
|
|
HeroMode(
|
|
enabled: false, // Disable all child hero animations
|
|
child: ListView(
|
|
children: [
|
|
Hero(tag: 'image1', child: Image.asset('1.png')),
|
|
Hero(tag: 'image2', child: Image.asset('2.png')),
|
|
],
|
|
),
|
|
)
|
|
```
|
|
|
|
### Conditional Hero Mode
|
|
|
|
```dart
|
|
HeroMode(
|
|
enabled: !_disableAnimations,
|
|
child: Hero(tag: 'image', child: Image.asset('image.png')),
|
|
)
|
|
```
|
|
|
|
## Debugging Hero Animations
|
|
|
|
### Slow Animation
|
|
|
|
```dart
|
|
void main() {
|
|
timeDilation = 10.0; // 10x slower
|
|
runApp(MyApp());
|
|
}
|
|
```
|
|
|
|
### Visualize Hero Bounds
|
|
|
|
```dart
|
|
void main() {
|
|
debugPaintSizeEnabled = true;
|
|
runApp(MyApp());
|
|
}
|
|
```
|
|
|
|
### Print Hero Tags
|
|
|
|
```dart
|
|
class DebugHero extends StatelessWidget {
|
|
const DebugHero({
|
|
super.key,
|
|
required this.tag,
|
|
required this.child,
|
|
});
|
|
|
|
final Object tag;
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
print('Building Hero with tag: $tag');
|
|
return Hero(tag: tag, child: child);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### DO
|
|
|
|
- Use unique, consistent tags (often the data object itself)
|
|
- Keep hero widget trees similar between routes
|
|
- Wrap images in `Material` with transparent color for "pop" effect
|
|
- Use `timeDilation` to debug transitions
|
|
- Consider `createRectTween` for custom flight paths
|
|
- Use `flightShuttleBuilder` for custom flight appearance
|
|
|
|
### DON'T
|
|
|
|
- Use duplicate tags (conflicts!)
|
|
- Change hero structure significantly between routes (jarring transition)
|
|
- Forget `Material` wrapper (no splash effect)
|
|
- Use very large images in heroes (performance)
|
|
- Ignore aspect ratio during transition (distortion)
|
|
|
|
## Common Patterns
|
|
|
|
### Grid to Fullscreen Image
|
|
|
|
**Grid item:**
|
|
```dart
|
|
PhotoHero(
|
|
photo: photo.url,
|
|
width: 150, // Smaller in grid
|
|
onTap: () => Navigator.push(..., detailScreen),
|
|
)
|
|
```
|
|
|
|
**Fullscreen:**
|
|
```dart
|
|
PhotoHero(
|
|
photo: photo.url,
|
|
width: MediaQuery.of(context).size.width, // Full width
|
|
onTap: () => Navigator.pop(),
|
|
)
|
|
```
|
|
|
|
### List Header to Page Header
|
|
|
|
**List:**
|
|
```dart
|
|
Hero(
|
|
tag: 'header',
|
|
child: SizedBox(
|
|
height: 200,
|
|
child: Image.asset('header.jpg'),
|
|
),
|
|
)
|
|
```
|
|
|
|
**Page:**
|
|
```dart
|
|
Hero(
|
|
tag: 'header',
|
|
child: SizedBox(
|
|
height: 300, // Larger on detail page
|
|
child: Image.asset('header.jpg'),
|
|
),
|
|
)
|
|
```
|
|
|
|
### Shared Element with Content Update
|
|
|
|
```dart
|
|
// In both routes
|
|
Hero(
|
|
tag: 'card',
|
|
child: Card(
|
|
child: Column(
|
|
children: [
|
|
Image.asset('image.png'),
|
|
Text(showDetails ? 'Full description...' : 'Brief description'),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
```
|
|
|
|
## Performance Tips
|
|
|
|
- Optimize hero images (compress, lazy load)
|
|
- Use `RepaintBoundary` around hero children if needed
|
|
- Test on low-end devices
|
|
- Profile with Flutter DevTools Performance overlay
|
|
- Avoid complex widget trees inside hero
|
|
|
|
## Accessibility
|
|
|
|
- Respect `MediaQuery.disableAnimations` setting
|
|
- Consider alternative navigation for users who prefer no animations
|
|
- Ensure hero content remains accessible during transition
|
|
- Test with screen readers
|
|
|
|
## Advanced Techniques
|
|
|
|
### Multiple Heroes on Same Route
|
|
|
|
```dart
|
|
class PhotoDetail extends StatelessWidget {
|
|
const PhotoDetail({super.key, required this.photos});
|
|
|
|
final List<String> photos;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Stack(
|
|
children: [
|
|
// Main photo hero
|
|
Positioned.fill(
|
|
child: Hero(
|
|
tag: photos[0],
|
|
child: Image.asset(photos[0]),
|
|
),
|
|
),
|
|
// Overlay hero (e.g., like button)
|
|
Positioned(
|
|
top: 16,
|
|
right: 16,
|
|
child: Hero(
|
|
tag: 'like-button',
|
|
child: Icon(Icons.favorite),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Nested Heroes
|
|
|
|
```dart
|
|
Hero(
|
|
tag: 'parent',
|
|
child: Card(
|
|
child: Column(
|
|
children: [
|
|
Hero(
|
|
tag: 'child-image',
|
|
child: Image.asset('image.png'),
|
|
),
|
|
Hero(
|
|
tag: 'child-title',
|
|
child: Text('Title'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
```
|
|
|
|
### Hero with Scroll Views
|
|
|
|
```dart
|
|
class ScrollableHero extends StatelessWidget {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return CustomScrollView(
|
|
slivers: [
|
|
SliverAppBar(
|
|
expandedHeight: 300,
|
|
flexibleSpace: FlexibleSpaceBar(
|
|
background: Hero(
|
|
tag: 'header-image',
|
|
child: Image.asset('header.jpg', fit: BoxFit.cover),
|
|
),
|
|
),
|
|
),
|
|
SliverList(
|
|
delegate: SliverChildListDelegate([
|
|
// Content
|
|
]),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
```
|