Hero 动画
Hero 动画实现两个页面之间共享元素的过渡效果——最常见的例子就是:在列表页点击一张小图片,图片"飞"到详情页变成大图。
一句话理解 Hero 动画
想象你在看漫画——主角从一格"嗖"地飞到下一格,虽然画面变了,但你知道那是同一个人。Hero 动画就是让两个页面的同一个元素"飞"过去的效果。
本章内容
- Hero 动画的工作原理
- 基本用法
- 列表页 → 详情页的完整实现
- 自定义 Hero 动画效果
- 常见问题与注意事项
工作原理
Hero 动画的本质是:两个页面中,用相同 tag 标记的 Widget 之间做过渡动画。
列表页 飞行过程 详情页
┌─────────────┐ ┌─────────────┐ ┌──────────────────────┐
│ │ │ │ │ │
│ 🖼️ 小图 │ ───→ │ 🖼️ 飞行中 │ ───→ │ 🖼️ 大图 │
│ tag: '1' │ │ │ │ tag: '1' │
│ │ │ │ │ │
└─────────────┘ └─────────────┘ └──────────────────────┘
页面 A(源) 系统自动生成 页面 B(目标)当你从页面 A 跳转到页面 B 时,Flutter 做了三件事:
- 计算位置:找到页面 A 中
tag: '1'的 Hero 的位置和大小 - 计算目标:找到页面 B 中
tag: '1'的 Hero 的位置和大小 - 播放动画:在页面切换过程中,让这个元素从 A 的位置/大小平滑过渡到 B 的位置/大小
你只需要在两个页面各放一个 Hero,设置相同的 tag,剩下的 Flutter 自动完成。
最简单的例子
页面 A(源页面)
class PageA extends StatelessWidget {
const PageA({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('列表页')),
body: Center(
child: GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const PageB()),
);
},
// ─── ★ Hero 共享元素动画 ──────────────
child: Hero(
// ─── ★ tag 唯一标识,两个页面必须一致 ──────────────
tag: 'my-image',
// ─── ☆ tag 唯一标识,两个页面必须一致 ──────────────
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
'https://picsum.photos/200',
width: 100,
height: 100,
fit: BoxFit.cover,
),
),
),
// ─── ☆ Hero 共享元素动画 ──────────────
),
),
);
}
}页面 B(目标页面)
class PageB extends StatelessWidget {
const PageB({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('详情页')),
body: Center(
child: Hero(
tag: 'my-image', // 和页面 A 相同的 tag
child: Image.network(
'https://picsum.photos/200',
width: double.infinity,
height: 300,
fit: BoxFit.cover,
),
),
),
);
}
}就这么简单!tag 相同的两个 Hero 之间会自动产生过渡动画。
tag 必须全局唯一
同一时刻,Widget 树中不能有两个相同 tag 的 Hero 同时可见。否则会报错:
There are multiple heroes that share the same tag within a subtree.列表页 → 详情页
实际开发中最常见的场景:列表中有多个元素,每个元素都能 Hero 跳转到详情页。关键是给每个列表项一个唯一的 tag。
数据模型
class Product {
final String id;
final String name;
final String imageUrl;
const Product({required this.id, required this.name, required this.imageUrl});
}列表页
class ProductListPage extends StatelessWidget {
final List<Product> products = const [
Product(id: '1', name: '商品A', imageUrl: 'https://picsum.photos/200?random=1'),
Product(id: '2', name: '商品B', imageUrl: 'https://picsum.photos/200?random=2'),
Product(id: '3', name: '商品C', imageUrl: 'https://picsum.photos/200?random=3'),
];
ProductListPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('商品列表')),
body: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: ProductCard(product: product),
);
},
),
);
}
}
class ProductCard extends StatelessWidget {
final Product product;
const ProductCard({super.key, required this.product});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => DetailPage(product: product),
),
);
},
child: Row(
children: [
Hero(
tag: 'product-${product.id}', // 用 id 生成唯一 tag
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
product.imageUrl,
width: 80,
height: 80,
fit: BoxFit.cover,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Text(product.name, style: const TextStyle(fontSize: 18)),
),
const Icon(Icons.chevron_right),
],
),
);
}
}详情页
class DetailPage extends StatelessWidget {
final Product product;
const DetailPage({super.key, required this.product});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Hero(
tag: 'product-${product.id}', // 和列表页相同的 tag
child: Image.network(
product.imageUrl,
width: double.infinity,
height: 300,
fit: BoxFit.cover,
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Text(
product.name,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text(
'这是商品的详细描述...',
style: TextStyle(fontSize: 16, color: Colors.grey),
),
),
],
),
);
}
}tag 的命名技巧
使用 ${类型}-${唯一标识} 的格式命名 tag,比如:
product-${product.id}— 商品图片avatar-${user.id}— 用户头像icon-${item.id}— 图标
这样不容易冲突,也容易理解。
自定义 Hero 动画
flightShuttleBuilder — 自定义飞行中的 Widget
默认情况下,飞行中的 Widget 就是源 Hero 的 child。但你可以用 flightShuttleBuilder 自定义飞行过程中的样式:
Hero(
tag: 'avatar',
flightShuttleBuilder: (
flightContext, // 飞行上下文
animation, // 动画对象(0.0 → 1.0)
flightDirection, // 飞行方向(push 或 pop)
fromHeroContext, // 源 Hero 的上下文
toHeroContext, // 目标 Hero 的上下文
) {
// 自定义飞行中的 Widget
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Material(
color: Colors.transparent,
child: Icon(
Icons.person,
size: 50 + (100 * animation.value), // 飞行中逐渐变大
),
);
},
);
},
child: const CircleAvatar(
radius: 25,
child: Icon(Icons.person),
),
)什么时候需要自定义?
大多数场景用默认效果就够了。需要自定义的典型场景:
- 源和目标的 Widget 差异很大(比如源是图标,目标是图片)
- 想在飞行中添加额外效果(如阴影、边框)
- 想根据飞行方向(push/pop)显示不同的效果
createRectTween — 自定义飞行路径
默认飞行路径是直线,你可以改成弧线:
Hero(
tag: 'avatar',
createRectTween: (begin, end) {
// 弧线路径
return MaterialRectArcTween(begin: begin, end: end);
},
child: const CircleAvatar(
radius: 25,
child: Icon(Icons.person),
),
)MaterialRectArcTween vs MaterialRectCenterArcTween
MaterialRectArcTween:弧线飞行,元素会沿弧线移动到目标位置MaterialRectCenterArcTween:弧线飞行,但只改变中心点位置,不改变大小
默认的飞行路径是 MaterialRectArcTween,大多数情况下不需要修改。
placeholderBuilder — 自定义源位置占位
当 Hero 元素飞走后,源位置会留一个"空位"。你可以用 placeholderBuilder 自定义占位内容:
Hero(
tag: 'avatar',
placeholderBuilder: (context, size, child) {
// Hero 飞走后,源位置显示一个灰色圆形
return Container(
width: size.width,
height: size.height,
decoration: const BoxDecoration(
color: Colors.grey,
shape: BoxShape.circle,
),
);
},
child: const CircleAvatar(
radius: 25,
child: Icon(Icons.person),
),
)Hero 动画参数速查
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
tag | Object | ✅ | 唯一标识,两个页面必须一致 |
child | Widget | ✅ | 要做动画的子组件 |
createRectTween | CreateRectTween? | ❌ | 自定义飞行路径 |
flightShuttleBuilder | FlightShuttleBuilder? | ❌ | 自定义飞行中的 Widget |
placeholderBuilder | PlaceholderBuilder? | ❌ | 自定义源位置占位 |
transitionOnUserGestures | bool | ❌ | iOS 手势返回时是否执行 Hero 动画,默认 false |
完整示例:商品列表 Hero 跳转
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Hero Demo',
theme: ThemeData(primarySwatch: Colors.blue),
home: const ProductListPage(),
);
}
}
// 数据模型
class Product {
final String id;
final String name;
final String imageUrl;
final String description;
const Product({
required this.id,
required this.name,
required this.imageUrl,
required this.description,
});
}
// 列表页
class ProductListPage extends StatelessWidget {
const ProductListPage({super.key});
static const _products = [
Product(
id: '1',
name: '商品 A',
imageUrl: 'https://picsum.photos/200?random=1',
description: '这是商品A的详细介绍,品质保证。',
),
Product(
id: '2',
name: '商品 B',
imageUrl: 'https://picsum.photos/200?random=2',
description: '这是商品B的详细介绍,限时折扣。',
),
Product(
id: '3',
name: '商品 C',
imageUrl: 'https://picsum.photos/200?random=3',
description: '这是商品C的详细介绍,新品上市。',
),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('商品列表')),
body: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _products.length,
itemBuilder: (context, index) {
final product = _products[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: Hero(
tag: 'product-${product.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
product.imageUrl,
width: 60,
height: 60,
fit: BoxFit.cover,
),
),
),
title: Text(product.name),
subtitle: Text(product.description, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => DetailPage(product: product),
),
);
},
),
);
},
),
);
}
}
// 详情页
class DetailPage extends StatelessWidget {
final Product product;
const DetailPage({super.key, required this.product});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Hero(
tag: 'product-${product.id}',
child: Image.network(
product.imageUrl,
width: double.infinity,
height: 280,
fit: BoxFit.cover,
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
product.description,
style: const TextStyle(fontSize: 16, color: Colors.grey),
),
],
),
),
],
),
);
}
}常见问题
1. tag 重复报错
There are multiple heroes that share the same tag within a subtree.原因:同一时刻有两个相同 tag 的 Hero 可见。
解决:确保每个 Hero 的 tag 全局唯一。在列表场景中,用 item.id 等唯一标识生成 tag:
// ❌ 错误:所有项都用相同的 tag
Hero(tag: 'product', ...)
// ✅ 正确:每项用唯一 tag
Hero(tag: 'product-${product.id}', ...)2. Hero 动画没有效果
常见原因:
- 两个 tag 不一致——检查拼写、大小写
- 没有用 Navigator 跳转——Hero 动画只在页面切换(
Navigator.push/pop)时生效,直接切换 Widget 不会触发 - tag 包含特殊字符——tag 可以是任意 Object,但建议只用 String 或 int
3. 两个 Hero 的 child 差异太大,动画不好看
Hero 动画的过渡效果依赖于"形状插值"。如果源和目标的形状差异太大(比如一个是圆形、一个是全宽矩形),过渡可能看起来很奇怪。
建议:两个 Hero 的 child 最好是同类型的 Widget(都是图片、都是图标等),且形状差异不要太大。
4. iOS 侧滑返回时 Hero 动画不生效
需要设置 transitionOnUserGestures: true:
Hero(
tag: 'avatar',
transitionOnUserGestures: true, // 启用手势返回时的 Hero 动画
child: ...,
)5. 能不能在 Tab 之间做 Hero 动画?
不能。Hero 动画只在 Navigator 的页面切换(push/pop)中生效。Tab 切换、PageView 切换都不支持 Hero 动画。
如果需要在 Tab 切换时做类似效果,可以使用 AnimatedSwitcher 或自定义 AnimatedWidget。
6. Hero 动画的性能
Hero 动画本质上是一个 Overlay 动画——飞行中的 Widget 是浮在所有页面之上的。所以:
- 飞行中的 Widget 应尽量简单,避免复杂的布局和大量的子组件
- 如果飞行中需要加载网络图片,确保图片已经缓存,否则可能出现闪烁
- 不要在
flightShuttleBuilder中做耗时操作
