Hero动画是Flutter最优雅的转场效果——共享元素在两个页面间平滑飞行,创造出令人惊叹的视觉连续性。本课深入Hero动画原理,从基础用法到自定义飞行路径,再到多元素同步过渡,让你的App拥有顶级应用般的交互体验。
Hero动画的核心非常简单:在两个页面中,用Hero Widget包裹同一个元素,给它们相同的tag,Flutter自动计算飞入/飞出动画。
// 列表页 - 用Hero包裹图片
class PhotoListPage extends StatelessWidget {
final List<Photo> photos;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('相册')),
body: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, crossAxisSpacing: 2, mainAxisSpacing: 2,
),
itemCount: photos.length,
itemBuilder: (context, index) {
return GestureDetector(
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => PhotoDetailPage(photo: photos[index]))),
child: Hero(
tag: 'photo_${photos[index].id}', // 唯一标识
child: Image.network(photos[index].thumbnailUrl, fit: BoxFit.cover),
),
);
},
),
);
}
}
// 详情页 - 同样用Hero包裹,相同的tag
class PhotoDetailPage extends StatelessWidget {
final Photo photo;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Hero(
tag: 'photo_${photo.id}', // 与列表页相同的tag
child: Image.network(photo.fullUrl, fit: BoxFit.contain),
),
),
);
}
}
// 使用flightShuttleBuilder自定义飞行过程的外观
Hero(
tag: 'photo_${photo.id}',
flightShuttleBuilder: (flightContext, animation, flightDirection,
fromHeroContext, toHeroContext) {
// 根据飞行方向选择不同的动画效果
final isForward = flightDirection == HeroFlightDirection.push;
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Opacity(
opacity: isForward ? animation.value : 1 - animation.value,
child: Transform.scale(
scale: 0.8 + 0.2 * animation.value,
child: Transform.rotate(
angle: isForward ? (1 - animation.value) * 0.1 : 0,
child: ClipRRect(
borderRadius: BorderRadius.circular(
16 * animation.value,
),
child: child,
),
),
),
);
},
child: isForward
? toHeroContext.widget
: fromHeroContext.widget,
);
},
child: Image.network(photo.thumbnailUrl, fit: BoxFit.cover),
)
一个页面可以有多个Hero同时过渡——例如商品列表中的商品图片和标题同步飞入详情页。
// 列表项 - 两个Hero元素
Widget buildProductItem(Product product) {
return GestureDetector(
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => ProductDetailPage(product: product))),
child: Column(
children: [
Hero(
tag: 'product_image_${product.id}',
child: Image.network(product.imageUrl, height: 150, fit: BoxFit.cover),
),
Hero(
tag: 'product_name_${product.id}',
child: Material(
color: Colors.transparent,
child: Text(product.name,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
Hero(
tag: 'product_price_${product.id}',
child: Material(
color: Colors.transparent,
child: Text('¥${product.price}',
style: TextStyle(fontSize: 18, color: Colors.red[700])),
),
),
],
),
);
}
// 详情页 - 匹配的Hero元素
class ProductDetailPage extends StatelessWidget {
final Product product;
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 300,
flexibleSpace: FlexibleSpaceBar(
background: Hero(
tag: 'product_image_${product.id}',
child: Image.network(product.imageUrl, fit: BoxFit.cover),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Hero(
tag: 'product_name_${product.id}',
child: Material(
color: Colors.transparent,
child: Text(product.name,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
),
),
const SizedBox(height: 8),
Hero(
tag: 'product_price_${product.id}',
child: Material(
color: Colors.transparent,
child: Text('¥${product.price}',
style: TextStyle(fontSize: 28, color: Colors.red[700])),
),
),
const SizedBox(height: 16),
Text(product.description),
],
),
),
),
],
),
);
}
}
// 在PageView中使用Hero - 需要确保tag唯一
class GalleryPage extends StatefulWidget {
final List<Photo> photos;
final int initialIndex;
@override
State<GalleryPage> createState() => _GalleryPageState();
}
class _GalleryPageState extends State<GalleryPage> {
late PageController _pageController;
@override
void initState() {
super.initState();
_pageController = PageController(initialPage: widget.initialIndex);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: PageView.builder(
controller: _pageController,
itemCount: widget.photos.length,
itemBuilder: (context, index) {
final photo = widget.photos[index];
return Center(
child: Hero(
// 关键:使用当前页的tag,不是初始页
tag: 'photo_${photo.id}',
child: Image.network(photo.fullUrl, fit: BoxFit.contain),
),
);
},
),
);
}
}
Material(color: Colors.transparent)包裹ListView中,不可见的Hero不会参与动画在列表项中嵌入Hero时,可能会遇到子Hero与父Hero冲突的问题。使用NestedHero模式解决。
// 嵌套Hero解决方案
// 问题:列表页Hero到详情页,但详情页内又有子Hero(如详情页内图片轮播)
class NestedHeroPage extends StatelessWidget {
final Product product;
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
// 主Hero - 商品图片
SliverAppBar(
expandedHeight: 300,
flexibleSpace: FlexibleSpaceBar(
background: Hero(
tag: 'product_image_${product.id}',
child: PageView.builder(
itemCount: product.images.length,
itemBuilder: (context, index) {
// 子图片不使用Hero,避免冲突
return Image.network(product.images[index], fit: BoxFit.cover);
},
),
),
),
),
// ...
],
),
);
}
}
// 方案2:使用HeroController自定义Hero行为
class CustomHeroController extends HeroController {
@override
Map<Type, GestureRecognizerFactory> createGestureRecognizerFactories() {
return {}; // 禁用默认手势,自定义过渡逻辑
}
}
在TabBarView中使用Hero需要特殊处理——因为多个tab的Hero可能同时存在。
// TabBarView中的Hero
// 关键:使用动态tag,确保每个tab只有当前可见页的Hero参与动画
class TabbedGallery extends StatefulWidget {
final List<PhotoCategory> categories;
@override
State<TabbedGallery> createState() => _TabbedGalleryState();
}
class _TabbedGalleryState extends State<TabbedGallery>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(
length: widget.categories.length, vsync: this,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: TabBar(
controller: _tabController,
tabs: widget.categories.map((c) => Tab(text: c.name)).toList(),
),
body: TabBarView(
controller: _tabController,
children: widget.categories.map((category) {
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
),
itemCount: category.photos.length,
itemBuilder: (context, index) {
final photo = category.photos[index];
return GestureDetector(
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => PhotoDetailPage(
photo: photo,
categoryTag: category.id, // 分类+ID确保唯一
)),
),
child: Hero(
// 关键:tag必须全局唯一
tag: '${category.id}_photo_${photo.id}',
child: Image.network(photo.thumbnail, fit: BoxFit.cover),
),
);
},
);
}).toList(),
),
);
}
}
将Hero动画与其他转场效果(缩放、滑动、淡入)组合,打造更丰富的视觉体验。
// 组合转场动画
class CombinedPageTransition<T> extends PageRouteBuilder<T> {
final Widget page;
CombinedPageTransition({required this.page})
: super(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 500),
reverseTransitionDuration: const Duration(milliseconds: 400),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// 滑动 + 淡入 + 缩放
final slideAnimation = Tween<Offset>(
begin: const Offset(0.1, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
));
final fadeAnimation = Tween<double>(
begin: 0.0, end: 1.0,
).animate(CurvedAnimation(
parent: animation,
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
));
final scaleAnimation = Tween<double>(
begin: 0.95, end: 1.0,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
));
return SlideTransition(
position: slideAnimation,
child: FadeTransition(
opacity: fadeAnimation,
child: ScaleTransition(
scale: scaleAnimation,
child: child,
),
),
);
},
);
}
// 旧页面的退出动画
// secondaryAnimation控制旧页面:
// 1. 淡出 + 轻微缩小
// 2. 向左轻微滑动
// 这些已由Flutter默认处理,但可以自定义
Curves.elasticOut让飞行更有弹性你已掌握Hero动画与页面转场!
下一课预告:自定义绘制与Canvas——用代码画出任意图形,突破Widget的极限!