Flutter Hero动画与页面转场—共享元素过渡与自定义PageRoute|新宇宙博客Back to listHero 动画与页面转场
Site Owner
Published on 2026-05-22
系统讲解Flutter Hero动画Overlay机制、自定义PageRoute转场、多元素协调动画及转场性能优化策略
Hero 动画与页面转场 (Hero Animation & Page Transitions)
模块:六 - 动画系统
预计阅读时间:25 分钟
前言
页面转场是移动应用中最重要的动画场景之一。一个流畅自然的转场动画能让用户感知到页面之间的空间关系,减少认知负担;而生硬的切换则会让应用显得廉价。
Flutter 提供了两套强大的页面转场工具:
- Hero 动画:跨页面共享元素的无缝过渡(如图片从列表"飞"到详情页)
- PageRouteBuilder:完全自定义的页面切换动画
本文深入这两套系统的实现原理,并提供可直接使用的工程级代码。
一、Hero 动画:共享元素的魔法
1.1 Hero 的工作原理
Hero 动画实现的视觉效果——元素从一个页面"飞"到另一个页面——背后的技术是 Overlay(浮层覆盖):
页面 A(来源) Overlay(导航遮罩层) 页面 B(目标)
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ │ │ │ │ │
│ ┌──────┐ │ 1. Hero A │ ┌──────────┐ │ 2.Hero │ ┌──────────┐ │
│ │Image │ ─── │ ──→ 隐藏 → │ │ 飞行中 │ │ B 隐藏 │ │ Image │ │
│ └──────┘ │ │ └──────────┘ │ ──→ │ └──────────┘ │
│ │ │ 动画期间此处 │ │ │
└─────────────────┘ │ 显示真实飞行 │ └─────────────────┘
└─────────────────┘
关键步骤:
- 导航开始时,Flutter 在 Overlay 上绘制 Hero 的"飞行副本"
- 来源页面的 Hero 和目标页面的 Hero 都被隐藏(透明)
- 飞行副本在 Overlay 上从来源 Rect 动画到目标 Rect
- 动画完成后,目标页面的 Hero 恢复显示,飞行副本消失
1.2 基础 Hero 使用
// 页面 A:列表中的缩略图
class PhotoListPage extends StatelessWidget {
final List<String> photos = ['photo_1.jpg', 'photo_2.jpg', 'photo_3.jpg'];
@override
Widget build(BuildContext context) {
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
),
itemCount: photos.length,
itemBuilder: (context, index) {
return GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PhotoDetailPage(photoId: photos[index]),
),
),
child: Hero(
tag: 'photo_${photos[index]}', // ⚠️ tag 必须全局唯一
child: Image.asset(photos[index], fit: BoxFit.cover),
),
);
},
);
}
}
// 页面 B:详情页
class PhotoDetailPage extends StatelessWidget {
const PhotoDetailPage({super.key, required this.photoId});
final String photoId;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Hero(
tag: 'photo_$photoId', // ⚠️ 必须与来源页面 tag 完全一致
child: Image.asset(photoId),
),
),
);
}
}
- 来源页面和目标页面都有
Hero Widget
- 两个
Hero 的 tag 必须完全相同(且全局唯一)
- 必须通过
Navigator 的路由切换来触发(直接 setState 不会触发)
1.3 Hero 的高级定制
flightShuttleBuilder:自定义飞行外观
飞行过程中默认使用目标 Hero 的 Widget,但可以用 flightShuttleBuilder 自定义:
Hero(
tag: 'avatar',
// 飞行时显示不同的 Widget(如加圆形裁剪)
flightShuttleBuilder: (
BuildContext flightContext,
Animation<double> animation, // 飞行进度 [0.0, 1.0]
HeroFlightDirection flightDirection, // push or pop
BuildContext fromHeroContext,
BuildContext toHeroContext,
) {
// animation.value: 0.0 = 来源状态,1.0 = 目标状态
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
// 从方形到圆形的平滑过渡
return ClipRRect(
borderRadius: BorderRadius.circular(
Tween<double>(begin: 8.0, end: 60.0).evaluate(animation),
),
child: child,
);
},
child: (flightDirection == HeroFlightDirection.push
? toHeroContext
: fromHeroContext)
.widget,
);
},
child: Image.network('https://example.com/avatar.jpg'),
)
placeholderBuilder:飞行期间来源位置的占位
Hero(
tag: 'product_image',
// 飞行期间来源位置显示占位(默认是透明的同尺寸空白)
placeholderBuilder: (context, heroSize, child) {
return Container(
width: heroSize.width,
height: heroSize.height,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: const Center(child: CircularProgressIndicator()),
);
},
child: Image.network('https://example.com/product.jpg'),
)
1.4 Hero 动画的常见问题
问题一:Hero 跳帧或位置错误
// ❌ 错误:Hero 包裹了有 padding/margin 的容器,导致坐标计算偏差
Hero(
tag: 'photo',
child: Padding(
padding: const EdgeInsets.all(8),
child: Image.asset('photo.jpg'),
),
)
// ✅ 正确:让 Hero 直接包裹视觉内容,padding 放在 Hero 外面
Padding(
padding: const EdgeInsets.all(8),
child: Hero(
tag: 'photo',
child: Image.asset('photo.jpg'),
),
)
问题二:Hero 动画只在 push 时有,pop 时没有
通常是因为目标页面的 Hero 被包裹在 FutureBuilder 或条件渲染中,pop 时还没有渲染出来:
// ❌ 问题代码:pop 时 Hero 可能不存在
FutureBuilder(
future: loadData(),
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox();
// pop 时如果数据还在加载,Hero 就不存在了
return Hero(tag: 'image', child: Image.network(snapshot.data!));
},
)
// ✅ 解决:即使在加载中也保留 Hero 结构
FutureBuilder(
future: loadData(),
builder: (context, snapshot) {
return Hero(
tag: 'image',
child: snapshot.hasData
? Image.network(snapshot.data!)
: Container(color: Colors.grey), // 占位符保持 Hero 存在
);
},
)
问题三:列表中多个 Hero 需要唯一 tag
// ✅ 用数据 ID 而不是 index 作为 tag
Hero(
tag: 'product_${product.id}', // 用唯一 ID
child: ProductImage(product: product),
)
Navigator.push(
context,
PageRouteBuilder(
// 必须:构建目标页面
pageBuilder: (context, animation, secondaryAnimation) {
return const TargetPage();
},
// 必须:定义过渡效果
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// animation: 当前页面的进度 [0.0 → 1.0](push时)
// secondaryAnimation: 当有新页面 push 到本页面之上时的进度
// child: pageBuilder 返回的目标页面
return FadeTransition(opacity: animation, child: child);
},
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250), // pop 时时长
),
);
2.2 常用转场效果实现
/// 淡入淡出
class FadePageRoute<T> extends PageRouteBuilder<T> {
FadePageRoute({required super.pageBuilder})
: super(
transitionsBuilder: (context, animation, _, child) {
return FadeTransition(
opacity: CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
),
child: child,
);
},
transitionDuration: const Duration(milliseconds: 250),
);
}
/// 从底部滑入(iOS Sheet 风格)
class SlideUpPageRoute<T> extends PageRouteBuilder<T> {
SlideUpPageRoute({required super.pageBuilder})
: super(
transitionsBuilder: (context, animation, _, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1), // 从底部(屏幕外)
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
)),
child: child,
);
},
transitionDuration: const Duration(milliseconds: 350),
);
}
/// 从右侧滑入 + 旧页面缩小退出(Android 风格)
class SharedAxisHorizontalRoute<T> extends PageRouteBuilder<T> {
SharedAxisHorizontalRoute({required super.pageBuilder})
: super(
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// 新页面从右侧滑入
final slideIn = Tween<Offset>(
begin: const Offset(1.0, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
));
// 旧页面向左缩小移出
final slideOut = Tween<Offset>(
begin: Offset.zero,
end: const Offset(-0.3, 0),
).animate(CurvedAnimation(
parent: secondaryAnimation, // 注意:用 secondaryAnimation
curve: Curves.easeInCubic,
));
return Stack(
children: [
// 旧页面(通过 SlideTransition 推走)
SlideTransition(
position: slideOut,
child: FadeTransition(
opacity: Tween<double>(begin: 1.0, end: 0.5)
.animate(secondaryAnimation),
child: child, // ⚠️ 这里的 child 是旧页面
),
),
// 新页面从右侧滑入
SlideTransition(
position: slideIn,
child: child,
),
],
);
},
);
}
2.3 视差(Parallax)转场效果
class ParallaxPageRoute<T> extends PageRouteBuilder<T> {
ParallaxPageRoute({required super.pageBuilder})
: super(
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return Stack(
children: [
// 旧页面:以 0.3 的速度移出(视差效果)
AnimatedBuilder(
animation: secondaryAnimation,
child: child,
builder: (context, child) => Transform.translate(
// 以较慢速度向左移动
offset: Offset(
-MediaQuery.of(context).size.width *
0.3 * secondaryAnimation.value,
0,
),
child: child,
),
),
// 新页面:全速从右侧进入
SlideTransition(
position: Tween<Offset>(
begin: const Offset(1.0, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOutQuart,
)),
child: child,
),
],
);
},
transitionDuration: const Duration(milliseconds: 400),
);
}
三、Material Motion 规范:SharedAxisTransition
Flutter 的 animations 包实现了 Material Design 3 的官方转场规范:
dependencies:
animations: ^2.0.0
import 'package:animations/animations.dart';
// 1. SharedAxisTransition(共轴过渡,适合同级页面切换)
Navigator.push(
context,
SharedAxisPageRoute(
builder: (_) => const DetailPage(),
transitionType: SharedAxisTransitionType.horizontal, // X轴
// SharedAxisTransitionType.vertical Y轴
// SharedAxisTransitionType.scaled 缩放
),
);
// 2. ContainerTransform(容器变换,适合从列表项展开到详情页)
OpenContainer(
closedBuilder: (context, openContainer) {
// 关闭状态(列表中的卡片)
return Card(
child: InkWell(
onTap: openContainer, // 点击触发展开
child: const ListTile(title: Text('点击展开')),
),
);
},
openBuilder: (context, closeContainer) {
// 打开状态(全屏详情页)
return const DetailPage();
},
closedElevation: 2,
openElevation: 4,
closedShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
)
// 3. FadeScaleTransition(淡入缩放,适合 FAB 触发的全屏对话框)
FadeScaleTransition(
animation: animation, // 传入页面动画
child: const MyDialog(),
)
四、手势驱动的交互式转场
4.1 InteractiveViewer + Hero 实现图片浏览
class PhotoViewer extends StatefulWidget {
const PhotoViewer({super.key, required this.imageUrl, required this.heroTag});
final String imageUrl;
final String heroTag;
@override
State<PhotoViewer> createState() => _PhotoViewerState();
}
class _PhotoViewerState extends State<PhotoViewer>
with SingleTickerProviderStateMixin {
late AnimationController _dragController;
Offset _dragOffset = Offset.zero;
double _dragScale = 1.0;
bool _isDragging = false;
@override
void initState() {
super.initState();
_dragController = AnimationController(vsync: this, value: 1.0);
}
@override
void dispose() {
_dragController.dispose();
super.dispose();
}
void _onVerticalDragUpdate(DragUpdateDetails details) {
setState(() {
_dragOffset += details.delta;
// 根据纵向偏移计算缩放(往下拖动时缩小)
_dragScale = (1 - _dragOffset.dy.abs() / 400).clamp(0.7, 1.0);
_isDragging = true;
});
}
void _onVerticalDragEnd(DragEndDetails details) {
if (_dragOffset.dy.abs() > 100) {
// 超过阈值:关闭页面
Navigator.pop(context);
} else {
// 未超过阈值:弹回原位
setState(() {
_dragOffset = Offset.zero;
_dragScale = 1.0;
_isDragging = false;
});
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onVerticalDragUpdate: _onVerticalDragUpdate,
onVerticalDragEnd: _onVerticalDragEnd,
child: Scaffold(
backgroundColor: Colors.black.withOpacity(_dragScale),
body: Center(
child: AnimatedContainer(
duration: _isDragging
? Duration.zero
: const Duration(milliseconds: 200),
transform: Matrix4.identity()
..translate(_dragOffset.dx, _dragOffset.dy)
..scale(_dragScale),
child: Hero(
tag: widget.heroTag,
child: InteractiveViewer(
child: Image.network(widget.imageUrl),
),
),
),
),
),
);
}
}
// iOS 风格:支持从左边缘滑动返回
Navigator.push(
context,
CupertinoPageRoute(
builder: (_) => const NextPage(),
// fullscreenDialog: true, // 全屏对话框样式(从底部滑入)
),
);
CupertinoPageRoute:从右侧滑入,支持左边缘手势返回
MaterialPageRoute on iOS:从右侧滑入,也支持手势返回
MaterialPageRoute on Android:从底部滑入,不支持手势返回
4.3 PopScope:控制页面返回行为
class ConfirmExitPage extends StatelessWidget {
const ConfirmExitPage({super.key});
@override
Widget build(BuildContext context) {
return PopScope(
// canPop: false 时,拦截所有物理/手势返回
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return; // 已经 pop 了,不需要处理
// 弹出确认对话框
_showExitDialog(context);
},
child: Scaffold(
appBar: AppBar(
leading: BackButton(
onPressed: () => _showExitDialog(context),
),
),
body: const Center(child: Text('有未保存的更改')),
),
);
}
Future<void> _showExitDialog(BuildContext context) async {
final shouldPop = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确定要退出吗?'),
content: const Text('您有未保存的更改'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('继续编辑'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('退出'),
),
],
),
);
if (shouldPop == true && context.mounted) {
Navigator.pop(context);
}
}
}
五、go_router 的自定义转场
使用 go_router 时,可以为每条路由配置转场:
import 'package:go_router/go_router.dart';
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (_, __) => const HomePage(),
),
GoRoute(
path: '/detail/:id',
// 使用 pageBuilder 而不是 builder 来自定义转场
pageBuilder: (context, state) {
final id = state.pathParameters['id']!;
return CustomTransitionPage(
key: state.pageKey,
child: DetailPage(id: id),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// 自定义:缩放 + 淡入
return ScaleTransition(
scale: Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
),
),
child: FadeTransition(opacity: animation, child: child),
);
},
transitionDuration: const Duration(milliseconds: 350),
);
},
),
],
);
// 全局默认转场(所有路由使用相同转场)
final routerWithGlobalTransition = GoRouter(
routes: [...],
// 注意:go_router 本身没有全局 transitionBuilder
// 通常通过自定义 Page 类实现
);
六、转场动画最佳实践
6.1 转场时长的规范
| 转场类型 | 推荐时长 | 原因 |
|---|
| 页面推入/弹出 | 250-350ms | 太快感觉粗糙,太慢感觉卡顿 |
| 模态底部弹出 | 300-400ms | 距离更长,需要更多时间 |
| 微交互(FAB 变化) | 200-250ms | 小元素变化要快 |
| Hero 动画 | 与页面转场同步 | 由 Flutter 自动控制 |
6.2 避免转场中的布局闪烁
// ❌ 问题:转场期间新页面还在加载数据,导致布局闪烁
class DetailPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: loadData(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox(); // 转场期间高度为 0,视觉闪烁!
}
return ContentWidget(data: snapshot.data!);
},
);
}
}
// ✅ 解决:使用骨架屏占位,保持布局稳定
class DetailPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: loadData(),
builder: (context, snapshot) {
return snapshot.hasData
? ContentWidget(data: snapshot.data!)
: const SkeletonLoader(); // 保持相同布局的骨架屏
},
);
}
}
6.3 Hero 与 ClipRRect 的配合
// Hero 飞行期间会暂时"逃出"ClipRRect 的裁剪区域
// 使用 heroAttributes 参数传入裁剪信息
Hero(
tag: 'card',
// 控制飞行中的圆角过渡
createRectTween: (begin, end) {
return MaterialRectCenterArcTween(begin: begin, end: end);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network('...'),
),
)
七、完整实战:图片浏览应用
综合 Hero + 自定义转场 + 手势返回的完整示例:
class PhotoGalleryApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
// 全局关闭默认转场(我们用自定义的)
theme: ThemeData(
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
},
),
),
home: const GalleryListPage(),
);
}
}
class GalleryListPage extends StatelessWidget {
const GalleryListPage({super.key});
final List<PhotoItem> photos = const [
PhotoItem(id: '1', url: 'https://picsum.photos/id/10/400/300', title: '自然风光'),
PhotoItem(id: '2', url: 'https://picsum.photos/id/20/400/300', title: '城市建筑'),
PhotoItem(id: '3', url: 'https://picsum.photos/id/30/400/300', title: '人文摄影'),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('图片画廊')),
body: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemCount: photos.length,
itemBuilder: (context, index) {
final photo = photos[index];
return GestureDetector(
onTap: () => Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (_, __, ___) => PhotoDetailPage(photo: photo),
transitionsBuilder: (_, animation, __, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
transitionDuration: const Duration(milliseconds: 300),
),
),
child: Hero(
tag: 'photo_${photo.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
fit: StackFit.expand,
children: [
Image.network(photo.url, fit: BoxFit.cover),
// 渐变遮罩 + 标题
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.6),
],
),
),
child: Text(
photo.title,
style: const TextStyle(color: Colors.white),
),
),
),
],
),
),
),
);
},
),
);
}
}
class PhotoDetailPage extends StatelessWidget {
const PhotoDetailPage({super.key, required this.photo});
final PhotoItem photo;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
iconTheme: const IconThemeData(color: Colors.white),
),
body: Center(
child: Hero(
tag: 'photo_${photo.id}',
child: InteractiveViewer(
child: Image.network(
photo.url,
fit: BoxFit.contain,
width: double.infinity,
),
),
),
),
);
}
}
class PhotoItem {
const PhotoItem({required this.id, required this.url, required this.title});
final String id;
final String url;
final String title;
}
八、过关自测
问题一:Hero 动画中 flightShuttleBuilder 的用途是什么?
flightShuttleBuilder 允许你自定义飞行过程中显示的 Widget。默认情况下飞行时显示目标 Hero 的 Widget,但有时需要特殊处理(如在飞行过程中添加圆形裁剪、阴影效果、或者完全不同的过渡外观)。flightShuttleBuilder 接收 animation(飞行进度),让你可以在飞行中插值变化 Widget 的外观。
问题二:secondaryAnimation 与 animation 的区别是什么?
animation:当前路由的进入/退出进度(push 时 0→1,pop 时 1→0)。secondaryAnimation:当有新路由 push 到此路由之上时此路由的进度(此路由被"遮盖"的程度)。用于实现"旧页面在新页面进入时向左退出"的效果——此时旧页面需要监听 secondaryAnimation 来做退出动画。
问题三:为什么 PopScope 中 canPop: false 还需要在 onPopInvokedWithResult 中检查 didPop?
当 canPop: false 时,系统确实不会 pop。但 onPopInvokedWithResult 仍会被调用,didPop 为 false。检查 if (didPop) return 是防御性编程——如果将来 canPop 条件改变(比如变成动态的),当 didPop 为 true 时说明系统已经自动 pop 了,我们就不需要再手动处理了。
总结
| 技术 | 适用场景 | 关键点 |
|---|
Hero | 跨页面共享元素 | tag 必须唯一;注意 Overlay 机制 |
flightShuttleBuilder | 自定义飞行外观 | 可以在飞行中做形状变换 |
PageRouteBuilder | 完全自定义转场 | secondaryAnimation 控制旧页退出 |
CupertinoPageRoute | iOS 原生手感 | 自动支持手势返回 |
OpenContainer | 容器展开转场 | Material Motion 官方规范 |
PopScope | 控制返回行为 | 替代已废弃的 WillPopScope |
页面转场的最高境界是:用户感知不到"切换",只感知到"内容在空间中移动"。Hero 动画是实现这一目标的最直接手段,而自定义 PageRouteBuilder 则让你完全掌控这段"空间旅行"的每一帧。