Flutter 基础体系 · 第 47/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter Hero 与页面转场:匹配、飞行、路由和视觉连续性
页面从一个路由切换到另一个路由时,默认转场通常是整页淡入、滑入或淡出。这样的动画能表达“页面发生了变化”,但无法说明两个页面中的某个对象其实是同一个对象。
例如,列表页中的缩略图点击后进入详情页。如果缩略图突然消失,详情页中的大图再从零出现,用户需要在视觉上重新建立对应关系。Hero 动画的作用,就是让这个对象跨越路由边界连续移动、缩放或变形:
列表页中的缩略图
│
│ push 详情路由
▼
同一个 tag 对应的 Hero 开始飞行
│
▼
详情页中的大图
这里的“Hero”不是独立的路由,也不是普通的 AnimationController。它是由 Navigator 管理的路由转场协调机制:在转场开始时,Flutter 查找前后两个路由中具有相同 tag 的 Hero,把其中的视觉内容放入覆盖层(overlay),再根据两个位置和大小生成动画。
一、先区分三个概念:路由、页面转场和 Hero
1. 路由是导航状态
Route 表示导航栈中的一个页面状态。使用 Navigator.push 时,新的路由被压入栈顶:
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const DetailPage(),
),
);
如果导航栈原来是:
[HomeRoute]
执行 push 后变成:
[HomeRoute, DetailRoute]
执行 pop 后,栈恢复为:
[HomeRoute]
路由负责页面的创建、进入、退出和动画生命周期。Hero 并不替代路由,它依附于两个路由之间已经发生的转场。
2. 页面转场是路由之间的视觉变化
页面转场由路由的 transitionDuration、reverseTransitionDuration 以及 buildTransitions 等机制控制。Material 路由和 Cupertino 路由会提供不同的默认页面转场。
因此一次 push 可能同时包含两种动画:
- 整个页面的转场,例如详情页从右侧滑入;
- 匹配到的
Hero对象从旧页面飞到新页面。
这两个动画通常并行执行,但它们是两套机制。即使没有任何 Hero,页面转场仍然可以正常发生;即使页面转场被设置为无动画,匹配的 Hero 也可能仍然需要根据路由动画驱动。
3. Hero 是跨路由的视觉连续性
“视觉连续性”指用户能把前一页面中的对象和后一页面中的对象识别为同一对象。它不是数据层面的对象共享,也不意味着两个页面使用同一个 Widget 实例。
例如下面两个 Hero 使用的是不同的 Widget 实例:
Hero(
tag: 'photo-42',
child: Image.network(thumbnailUrl),
)
Hero(
tag: 'photo-42',
child: Image.network(fullSizeUrl),
)
只要它们位于参与同一次转场的两个路由中,并且 tag 匹配,Flutter 就可以把它们视为一对起点和终点。
二、Hero 匹配的形式化条件
设当前路由为 ,即 source route;即将进入的路由为 ,即 destination route。
对于每个路由,定义其中可参与匹配的 Hero 集合:
其中:
- 是
Hero.tag; - 是对应的
Hero; tag的匹配使用 Dart 的相等关系,也就是==和hashCode语义,而不只是字符串内容。
一对 Hero 可以参与飞行,至少需要满足以下条件:
此外,在每一个参与匹配的路由中,同一个 tag 应该至多对应一个 Hero:
因此,下面的结构是有效的:
旧路由:tag = photo-42
新路由:tag = photo-42
下面的结构会产生冲突:
旧路由:
Hero(tag: photo-42)
Hero(tag: photo-42)
因为 Flutter 无法判断旧路由中的哪一个对象应该飞向新路由中的对象。常见结果是调试模式下出现断言错误,或者该匹配无法按预期建立。
tag 必须表达“同一个语义对象”
不要为了让动画运行而在两处随意写相同字符串:
Hero(tag: 'image', child: ...)
如果一个页面中有多个图片,这会使它们的语义身份冲突。更可靠的写法是使用业务实体的稳定 ID:
Hero(
tag: 'photo-${photo.id}',
child: Image.network(photo.thumbnailUrl),
)
如果使用对象作为 tag,例如:
Hero(
tag: photo,
child: ...,
)
则必须注意该对象的 == 和 hashCode 是否稳定。页面重建过程中,如果对象被替换成另一个实例且两者不相等,Hero 将无法匹配;如果自定义相等关系不正确,也可能造成意外匹配。
三、一次 Hero 转场究竟发生了什么
假设列表页中的缩略图位于矩形区域:
详情页中的大图位于:
其中:
- 是相对于 Navigator 覆盖层坐标系的位置;
- 是布局后的宽度和高度;
- 是起始矩形;
- 是目标矩形。
转场动画会产生一个进度值:
通常 表示动画刚开始, 表示动画结束。矩形插值可以抽象为:
如果使用简单线性插值,则:
实际 Flutter 实现可以使用 RectTween 的不同子类,让路径不是简单地同时线性改变四个边。例如 Material 风格可能使用弧线性质的矩形过渡。这里的关键不是某一种默认曲线,而是:Hero 的飞行几何来自起点矩形、终点矩形和路由动画进度。
一次典型过程可以分成以下步骤:
Navigator.push开始,旧路由和新路由参与转场;- Flutter 在两个路由的 Hero 子树中收集 tag;
- 找到相等的 tag;
- 读取起点和终点的布局矩形;
- 创建飞行中的 Hero;
- 飞行中的内容被放到 Navigator 的 overlay 中,因此可以暂时绘制在两个页面之上;
- 原来位置的 Hero 可以通过占位机制保留布局空间;
- 路由动画推进,飞行内容的位置和大小随进度变化;
- 动画结束后,飞行内容移除,目标路由中的 Hero 恢复正常绘制。
可以把它表示为:
sequenceDiagram
participant U as 用户
participant N as Navigator
participant S as 源路由
participant D as 目标路由
participant O as Navigator Overlay
U->>N: push(目标路由)
N->>S: 读取 Hero(tag)
N->>D: 构建并读取 Hero(tag)
N->>N: 匹配相等 tag
N->>O: 创建飞行中的 Hero
N->>S: 使用占位内容维持布局
N->>O: 根据路由动画更新位置和尺寸
O-->>U: Hero 在页面之间飞行
N->>D: 转场完成
N->>O: 移除飞行中的 Hero
D-->>U: 显示目标路由中的 Hero
这也解释了一个常见现象:飞行中的内容可能暂时绘制在页面内容、AppBar 或其他组件之上。它并不是继续待在原来列表项的布局位置,而是在 Navigator 的覆盖层中独立绘制。
四、一个完整可运行的列表到详情示例
下面的示例可以放入一个新建 Flutter 项目的 lib/main.dart 中运行。它展示:
- 列表页和详情页如何使用相同 tag;
MaterialPageRoute如何触发 Hero;- 两个页面如何使用不同尺寸的图片;
- 为什么必须保证图片在两个页面中可构建。
import 'package:flutter/material.dart';
void main() {
runApp(const PhotoApp());
}
class Photo {
const Photo({
required this.id,
required this.title,
required this.imageUrl,
});
final int id;
final String title;
final String imageUrl;
}
const photos = <Photo>[
Photo(
id: 1,
title: '山谷',
imageUrl: 'https://picsum.photos/id/1018/1200/800',
),
Photo(
id: 2,
title: '森林',
imageUrl: 'https://picsum.photos/id/1015/1200/800',
),
Photo(
id: 3,
title: '海边',
imageUrl: 'https://picsum.photos/id/1016/1200/800',
),
];
class PhotoApp extends StatelessWidget {
const PhotoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Hero Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const PhotoListPage(),
);
}
}
class PhotoListPage extends StatelessWidget {
const PhotoListPage({super.key});
String heroTag(Photo photo) => 'photo-${photo.id}';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('照片列表')),
body: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: photos.length,
itemBuilder: (context, index) {
final photo = photos[index];
return Card(
clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.only(bottom: 16),
child: InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PhotoDetailPage(photo: photo),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Hero(
tag: heroTag(photo),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Image.network(
photo.imageUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const ColoredBox(
color: Colors.black12,
child: Center(
child: Icon(Icons.broken_image),
),
);
},
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Text(
photo.title,
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
),
);
},
),
);
}
}
class PhotoDetailPage extends StatelessWidget {
const PhotoDetailPage({
required this.photo,
super.key,
});
final Photo photo;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(photo.title)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Hero(
tag: 'photo-${photo.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.network(
photo.imageUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const AspectRatio(
aspectRatio: 4 / 3,
child: ColoredBox(
color: Colors.black12,
child: Center(
child: Icon(Icons.broken_image, size: 48),
),
),
);
},
),
),
),
const SizedBox(height: 24),
Text(
'这是 ${photo.title} 的详情页。',
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
);
}
}
这个示例为什么能够匹配
以第一张照片为例:
列表页:Hero(tag: photo-1)
详情页:Hero(tag: photo-1)
PhotoListPage 中的图片在列表项中大约是一个横向矩形,PhotoDetailPage 中的图片则占据更大的内容区域。两个矩形不同并不是问题,Hero 正是根据这两个布局结果计算过渡。
点击列表项后,输入是一个 Photo 对象,输出是:
- 一个被压入 Navigator 的详情路由;
- 列表中的
photo-1和详情中的photo-1被匹配; - 图片从列表位置过渡到详情位置;
- 路由转场完成后显示详情页。
网络图片的风险是:如果目标页面构建时图片尚未加载,飞行中的视觉内容可能出现短暂占位或前后图像不一致。生产代码通常会使用缓存、预加载或明确的加载占位策略,但这些属于资源加载问题,不改变 Hero 的匹配规则。
五、Hero 的生命周期和页面状态
源路由通常不会立刻销毁
执行 push 时,源路由通常仍然存在于导航栈中,只是被新路由覆盖。正因为源路由还能参与转场,Flutter 才能从它读取 Hero 的起始几何信息。
执行 pop 时,过程反过来:
详情页的 Hero → 列表页的 Hero
这要求列表页返回时仍然能构建出同一个 tag。以下情况可能导致返回动画失败:
- 列表数据已经被刷新,原来的 item 不在列表中;
- 列表滚动位置变化,目标 item 尚未布局;
- 页面状态重置,导致 tag 改变;
- 页面树被条件分支替换,Hero 暂时不存在。
例如,详情页返回时,如果列表页中 photo-1 已经被删除,那么就不存在有效的目标 Hero。页面仍然可以返回,但不会得到期望的 Hero 飞行动画。
Hero 不是数据同步机制
飞行结束后,目标路由中的 Widget 会继续负责正常绘制。因此不要把“飞行中的 Hero”当成可以修改业务状态的对象:
Hero(
tag: photo.id,
child: StatefulImageWidget(...),
)
飞行过程中显示的内容可能是由 flightShuttleBuilder 提供的副本或替代内容。业务数据应该由页面状态、状态管理器或路由参数维护,而不是依赖 Hero 本身。
六、Hero 的主要配置点
tag
tag 是匹配身份。它可以是字符串、数字或其他对象,但必须满足:
- 源路由和目标路由中的值相等;
- 同一个路由内不要重复;
- 在一次转场期间保持稳定;
- 能够表达真实的业务对象身份。
child
child 是 Hero 的视觉内容。Hero 并不要求前后两边的 child 完全相同,但差异越大,飞行期间越可能出现突变。
例如,源页面使用圆角缩略图,目标页面使用完全不同的图标:
源:照片缩略图
目标:播放按钮
从技术上可能仍可匹配,但视觉上并不连续。Hero 解决的是几何和视觉承接,不会自动判断两个 child 是否具有语义上的相似性。
createRectTween
该属性用于控制起点矩形到终点矩形的插值方式。它影响“怎么飞”,不影响“是否匹配”。
一个简单的自定义示例:
Hero(
tag: 'photo-1',
createRectTween: (begin, end) {
if (begin == null || end == null) {
return null;
}
return RectTween(begin: begin, end: end);
},
child: const Icon(Icons.photo),
)
这里使用的是简单的 RectTween。begin 或 end 可能为空,因此不能无条件解包。自定义插值时还要意识到:矩形路径改变后,图片的缩放、裁剪和边界变化可能与页面转场曲线不协调。
flightShuttleBuilder
它决定飞行期间具体绘制什么内容。典型用途包括:
- 源页面是低分辨率缩略图,飞行时想使用统一的图片组件;
- 前后页面的 child 结构不同;
- 想在飞行过程中固定文字、圆角或阴影;
- 需要根据
HeroFlightDirection区分 push 和 pop。
示例:
Hero(
tag: 'photo-1',
flightShuttleBuilder: (
flightContext,
animation,
flightDirection,
fromHeroContext,
toHeroContext,
) {
return Material(
color: Colors.transparent,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.network(
'https://picsum.photos/id/1018/1200/800',
fit: BoxFit.cover,
),
),
);
},
child: Image.network(
'https://picsum.photos/id/1018/300/200',
fit: BoxFit.cover,
),
)
这里的返回值是飞行期间的 Widget。它不应依赖已经失效的页面状态;如果使用 fromHeroContext 或 toHeroContext 查找上下文中的组件,需要考虑页面在动画中的生命周期和布局变化。
placeholderBuilder
飞行期间,原位置通常需要保留空间,否则列表项会突然塌缩。placeholderBuilder 可以定义原位置在飞行时显示什么:
Hero(
tag: 'photo-1',
placeholderBuilder: (context, size, child) {
return SizedBox(
width: size.width,
height: size.height,
child: const ColoredBox(color: Colors.black12),
);
},
child: const SizedBox(
width: 120,
height: 80,
child: ColoredBox(color: Colors.blue),
),
)
size 是占位区域的尺寸。占位组件如果尺寸处理错误,可能导致源页面布局发生变化,进而影响飞行矩形和用户看到的连续性。
transitionOnUserGestures
该属性控制 Hero 是否参与用户手势驱动的路由转场。它对 iOS 风格的交互式返回尤其重要:
Hero(
tag: 'photo-1',
transitionOnUserGestures: true,
child: ...,
)
普通 push 和 pop 使用路由动画控制进度;交互式返回则可能由用户拖动手势控制进度。因此 Hero 必须能够处理非线性的、被暂停或反向推进的动画状态。
这不等于所有平台都会自动提供相同的返回手势。手势是否存在、由哪个路由提供、系统返回行为如何,取决于路由类型、平台和 Flutter 当前的导航实现。
七、路由和 Navigator 的边界
Hero 默认只在同一个 Navigator 内匹配
Hero 控制器观察的是某个 Navigator 的路由变化。因此下面的结构通常不能直接跨越匹配:
Navigator A
└── 页面中的 Hero(tag: item-1)
Navigator B
└── 页面中的 Hero(tag: item-1)
即使两个 tag 完全相同,它们也属于不同的导航协调范围。
这在以下结构中很常见:
- 根页面有一个 Navigator;
- Tab 页面各自维护一个嵌套 Navigator;
- Shell 路由、嵌套路由或对话框内部又创建了 Navigator。
如果对象从一个 Navigator 的页面进入另一个 Navigator 的页面,不能仅靠复制 tag 实现 Hero 飞行。解决方向是调整导航层级,让两个路由由同一个 Navigator 管理,或者自行实现跨 Navigator 的动画。
同一个 Navigator 的多个路由才具有直接关系
例如:
MaterialApp(
home: const HomePage(),
)
MaterialApp 默认创建并管理顶层 Navigator。使用该 Navigator 执行:
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const DetailPage(),
),
);
列表页和详情页就处于同一个导航协调范围内。
如果 BuildContext 位于嵌套 Navigator 内,Navigator.of(context) 找到的可能不是顶层 Navigator。此时需要明确导航边界,例如使用正确的上下文,或在确有必要时使用:
Navigator.of(context, rootNavigator: true).push(...);
但这会改变页面进入的导航层级,也可能影响返回栈、模态层和状态保存,不能仅为了 Hero 而盲目使用。
HeroController 是 Navigator 的观察者
Hero 动画由 HeroController 协调。Material 应用通常已经为默认 Navigator 配置了相应控制器,因此最基本的 Hero 示例不需要手动配置。
如果应用手动创建 Navigator,或者需要自定义 Hero 控制器,可以配置 observers:
class CustomNavigationHost extends StatelessWidget {
const CustomNavigationHost({super.key});
@override
Widget build(BuildContext context) {
return Navigator(
observers: <NavigatorObserver>[
HeroController(),
],
onGenerateRoute: (settings) {
return MaterialPageRoute<void>(
settings: settings,
builder: (_) => const PhotoListPage(),
);
},
);
}
}
这里的前提是该 Navigator 的页面中确实存在可匹配的 Hero。手动创建 Navigator 时还要自行处理初始路由、路由生成和返回行为;只添加一个观察者并不能自动修复错误的 tag 或错误的导航层级。
不同 Flutter 版本的 HeroController 构造能力和默认转场细节可能存在变化。自定义时应以当前 SDK 的 API Reference 为准,不应假定所有路由都使用同一个默认矩形插值策略。
八、页面转场与 Hero 如何组合
Hero 不是页面转场的替代物。一个完整转场通常有两条并行路径:
路由动画进度
├──> 页面进入/退出动画
└──> Hero 起点到终点的飞行动画
以 MaterialPageRoute 为例:
- 路由创建并进入 Navigator;
- 新路由构建页面;
- 页面转场动画产生进度;
- Hero 使用相关动画进度更新自身位置;
- 页面背景、AppBar、列表等按照路由转场规则绘制;
- Hero 在覆盖层中独立绘制。
因此,以下代码只是改变页面转场,不会改变 Hero 的 tag 匹配:
Navigator.of(context).push(
PageRouteBuilder<void>(
pageBuilder: (context, animation, secondaryAnimation) {
return const PhotoDetailPage(
photo: photos[0],
);
},
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
),
);
如果两个页面中的 tag 匹配,Hero 仍可能参与;如果 tag 不匹配,页面仍会淡入,但没有对象飞行。
自定义路由时的风险
自定义 PageRouteBuilder 时,以下行为可能破坏预期:
pageBuilder返回的页面没有构建目标 Hero;- 页面在动画期间被条件逻辑替换;
- 路由转场立即完成,飞行时间接近于零;
- 自定义
transitionsBuilder改变了布局,使目标 Hero 的矩形不稳定; - 使用不兼容的路由组合,导致用户手势和 Hero 进度不同步。
自定义转场的验证方式不是只看页面是否出现,而是分别验证:
- 页面转场是否按预期执行;
- Hero 是否被匹配;
- Hero 起点和终点是否正确;
- push 和 pop 是否都连续;
- 手势返回中是否仍然连续。
九、常见失败表现和诊断路径
1. 页面正常跳转,但没有 Hero
优先检查以下条件:
旧路由是否存在 Hero?
新路由是否存在 Hero?
两个 tag 是否真正相等?
两个 Hero 是否属于同一个 Navigator?
目标 Hero 在转场开始时是否已经布局?
可以先把 tag 简化为固定值验证导航边界:
Hero(
tag: 'debug-hero',
child: ...,
)
如果固定值仍无动画,问题更可能在 Navigator 层级、路由实现或页面构建时机;如果固定值有效,再检查业务 ID、对象相等关系和条件渲染。
2. 出现重复 tag 断言
下面的列表代码有风险:
ListView(
children: photos.map((photo) {
return Hero(
tag: 'photo',
child: Image.network(photo.imageUrl),
);
}).toList(),
)
同一个路由内的所有 Hero 都使用了 photo,违反了唯一性要求。应改为:
Hero(
tag: 'photo-${photo.id}',
child: Image.network(photo.imageUrl),
)
如果列表中可能出现重复业务 ID,也必须先修复数据身份问题,而不是继续修改动画参数。
3. 飞行过程中出现黑块、闪烁或内容跳变
可能原因包括:
- 图片源页面和目标页面的加载状态不同;
flightShuttleBuilder返回了尺寸不稳定的 Widget;BoxFit、裁剪区域或圆角在前后页面差异过大;- 源页面在飞行期间发生滚动或重排;
- 占位组件没有保持原始尺寸。
诊断时可以先移除自定义的 flightShuttleBuilder、placeholderBuilder 和自定义矩形插值,使用最小 Hero 验证基本匹配,再逐项恢复自定义逻辑。
4. 返回时没有飞回列表中的原位置
这不是“pop 不支持 Hero”,而通常是返回目标缺失或几何信息不可用:
- 列表项已经不在当前 widget 树中;
- 列表滚动到了其他位置;
- 使用
ListView.builder时目标项尚未构建; - 返回前数据源改变;
- 列表页面被重新创建,原状态没有保留。
对于长列表,滚出视口的 item 可能没有对应的 RenderObject。Hero 需要一个可测量的起点或终点,因此“数据存在”不等于“目标 Hero 当前已布局”。
5. Hero 被错误地认为会跨对话框或嵌套页面
showDialog、底部表单、嵌套 Navigator 等场景可能引入不同的路由或 Navigator。两个 tag 相同不能证明它们处于同一匹配范围。应在 Widget Inspector 中检查实际的 Navigator 树,而不是只观察源代码的页面层次。
十、HeroMode 和条件参与
有时某个页面树中存在 Hero,但希望暂时禁止它参与转场,可以使用 HeroMode:
HeroMode(
enabled: false,
child: Hero(
tag: 'photo-1',
child: ...,
),
)
HeroMode 适合处理:
- 同一内容在多个页面层次中重复出现;
- 某个页面处于缓存、预览或非活动状态;
- 暂时不希望一个 Hero 参与当前导航转场。
它不会删除 Widget,也不会改变业务数据,只是控制该子树中的 Hero 是否参与匹配。若禁用后另一侧仍期待匹配,结果自然是没有飞行动画。
十一、语义、可访问性和视觉层级
Hero 的飞行内容会暂时脱离原来的布局位置并进入覆盖层。视觉上它可能覆盖 AppBar、底部栏或其他内容,因此要检查:
- 飞行内容是否需要 Material 背景;
- 阴影和圆角是否在飞行过程中连续;
- 文字是否重复绘制;
- 无障碍树中是否出现重复语义。
Hero 提供 excludeFromSemantics 选项,用于控制飞行相关内容是否排除在语义树之外。视觉副本如果会导致屏幕阅读器重复读取,应根据实际页面结构调整,而不是仅凭动画效果判断正确性。
此外,Hero 动画不能替代可访问的页面状态变化。目标页面仍然应提供正确的标题、焦点顺序、返回操作和语义标签。
十二、平台差异:Android、iOS、桌面和 Web
Android
Android 上常见的是 Material 风格的页面转场,系统返回通常表现为路由栈的 pop。Hero 的核心匹配和飞行机制仍由 Flutter 控制,但系统返回手势、预测性返回以及具体页面动画是否参与,会受 Flutter 版本、Android 版本和路由实现影响。
不能因为 Android 系统支持某种返回手势,就假定所有自定义路由都能自动得到一致的 Hero 交互效果。需要实际测试:
- 手势或系统返回开始时 Hero 是否参与;
- 返回被取消时 Hero 是否能恢复;
- 返回完成时目标 Hero 是否仍然存在。
iOS
iOS 风格路由常见交互式侧滑返回。若路由实现支持用户手势,transitionOnUserGestures 会影响 Hero 是否跟随该手势进度。
iOS 的重点风险是“取消返回”:用户拖动页面一部分距离后松手,路由可能恢复原状态。Hero 必须能够随动画反向推进,源页面和目标页面也必须继续可布局。不要在手势尚未结束时销毁其中一侧的关键页面状态。
桌面端
桌面端通常没有移动端相同的系统返回手势,窗口尺寸、鼠标交互和键盘快捷键更重要。Hero 的基本机制仍然适用,但大屏布局经常导致源矩形和目标矩形差异很大,简单的缩放可能产生突兀效果。
桌面端应特别验证:
- 窗口调整大小过程中起点和终点是否稳定;
- 鼠标快速连续点击是否重复 push;
- 键盘返回或自定义导航操作是否正确 pop;
- 大尺寸图片飞行时的裁剪和性能表现。
Web
Flutter Web 中,应用内使用 Navigator 或 Router 进行的路由变化可以使用 Flutter 的 Hero 机制。Web 浏览器地址栏、前进后退按钮则依赖路由状态与浏览器历史的集成方式。
因此必须区分:
Flutter Navigator 的 push/pop
浏览器历史记录的 back/forward
如果应用使用 MaterialApp.router 和 Router API,浏览器历史变化可能映射为路由状态变化;但这不代表任意 URL 变化都会自动拥有和应用内 push 完全相同的 Hero 起点。直接刷新页面、深链接进入详情页时通常没有之前的列表路由,因此没有可匹配的源 Hero,页面应能在没有 Hero 的情况下独立正确显示。
Web 还要考虑:
- 图片解码和网络加载时机;
- 浏览器窗口尺寸变化;
- 鼠标悬停、键盘焦点和减少动画偏好;
- URL 导航导致的页面直接创建。
十三、性能和视觉连续性的实际取舍
Hero 飞行期间通常会增加一个覆盖层中的视觉对象。对于简单的颜色块、图标和小图片,开销通常可接受;对于大尺寸图片、复杂阴影、实时视频或嵌套动画,飞行期间可能增加布局、绘制和纹理压力。
性能问题应先通过测量确认,而不是假定 Hero 本身一定昂贵。可以在 profile 模式下观察帧耗时和栅格化情况,并检查:
- 飞行 Widget 是否重复执行昂贵构建;
- 图片是否发生额外解码;
- 是否存在多个同时飞行的 Hero;
flightShuttleBuilder是否返回复杂动画树;- 是否在每一帧触发业务状态更新。
视觉连续性也存在取舍。源图和目标图完全一致,通常最稳定;但为了避免低分辨率图片放大模糊,飞行期间使用统一的高质量图片可能更好。此时要接受资源加载延迟,并用占位或预加载处理过渡阶段。
十四、什么时候不应该使用 Hero
以下场景中,普通页面转场或自定义动画可能更准确:
- 前后页面中的对象没有明确的一一对应关系;
- 对象不是跨页面移动,而是页面整体展开;
- 两个 Navigator 之间需要跨边界共享动画;
- 动画需要复杂的物理效果、粒子效果或多个对象编排;
- 目标页面可能在转场开始时不存在稳定布局;
- 视觉连续性会让用户误以为两个不相关对象是同一个对象。
Hero 最适合表达:
列表项 → 详情项
头像 → 个人资料页头像
商品缩略图 → 商品详情图
卡片 → 展开后的同一张卡片
它不适合用来掩盖错误的路由数据、缺失的页面状态或不稳定的组件结构。
十五、一个可靠的排查顺序
当 Hero 不工作时,按以下因果顺序排查比直接修改动画参数更有效:
第一步:确认导航确实发生在同一个 Navigator
检查源页面和目标页面实际使用的 Navigator。嵌套 Navigator 是最常见的边界原因。
第二步:确认两侧都在转场时构建了 Hero
不要只检查源页面代码。目标页面必须在路由进入阶段构建包含相同 tag 的 Hero。
第三步:确认 tag 的相等性和唯一性
打印业务 ID 或 tag:
debugPrint('source tag: ${photo.id}');
检查两侧是否真正相等,并确认每个路由内没有重复值。
第四步:移除定制项验证基础机制
暂时移除:
flightShuttleBuilderplaceholderBuildercreateRectTween- 自定义页面转场
先验证最小的 Hero(tag: ..., child: ...) 是否能够飞行。
第五步:检查布局和状态生命周期
确认源、目标矩形在动画开始时都可测量,列表项没有因滚动或数据更新而消失,返回时目标页面没有被重新初始化成不同结构。
第六步:分别测试 push、pop 和交互式返回
push 能飞,不代表 pop 一定能飞;普通 pop 能飞,也不代表手势取消和反向动画没有问题。
总结
Hero 的核心不是“给 Widget 加一个动画标签”,而是让同一个 Navigator 管理的两个路由,在转场期间基于相等的 tag 建立视觉对应关系。
完整条件可以概括为:
匹配成功后,Flutter 会读取两个 Hero 的矩形,把飞行内容放入 Navigator overlay,并使用路由动画进度计算位置和尺寸。路由负责页面导航,页面转场负责整体页面变化,Hero 负责其中具有语义连续性的对象。
掌握这条数据流之后,tag 冲突、嵌套 Navigator、列表返回失败、图片闪烁、自定义 shuttle 和平台返回手势等问题,都可以还原为几个可验证的条件:谁在导航、谁被构建、tag 是否相等、矩形是否存在,以及飞行期间页面状态是否仍然稳定。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 显式动画:Controller、Ticker、组合、清理和测试
- 下一篇:Flutter go_router:声明式路由、重定向、Shell、Deep Link 和恢复
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论