Flutter 基础体系 · 第 47/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter Hero 与页面转场:匹配、飞行、路由和视觉连续性

页面从一个路由切换到另一个路由时,默认转场通常是整页淡入、滑入或淡出。这样的动画能表达“页面发生了变化”,但无法说明两个页面中的某个对象其实是同一个对象。

例如,列表页中的缩略图点击后进入详情页。如果缩略图突然消失,详情页中的大图再从零出现,用户需要在视觉上重新建立对应关系。Hero 动画的作用,就是让这个对象跨越路由边界连续移动、缩放或变形:

列表页中的缩略图
        │
        │  push 详情路由
        ▼
同一个 tag 对应的 Hero 开始飞行
        │
        ▼
详情页中的大图

这里的“Hero”不是独立的路由,也不是普通的 AnimationController。它是由 Navigator 管理的路由转场协调机制:在转场开始时,Flutter 查找前后两个路由中具有相同 tagHero,把其中的视觉内容放入覆盖层(overlay),再根据两个位置和大小生成动画。


一、先区分三个概念:路由、页面转场和 Hero

1. 路由是导航状态

Route 表示导航栈中的一个页面状态。使用 Navigator.push 时,新的路由被压入栈顶:

Navigator.of(context).push(
  MaterialPageRoute<void>(
    builder: (_) => const DetailPage(),
  ),
);

如果导航栈原来是:

[HomeRoute]

执行 push 后变成:

[HomeRoute, DetailRoute]

执行 pop 后,栈恢复为:

[HomeRoute]

路由负责页面的创建、进入、退出和动画生命周期。Hero 并不替代路由,它依附于两个路由之间已经发生的转场。

2. 页面转场是路由之间的视觉变化

页面转场由路由的 transitionDurationreverseTransitionDuration 以及 buildTransitions 等机制控制。Material 路由和 Cupertino 路由会提供不同的默认页面转场。

因此一次 push 可能同时包含两种动画:

  1. 整个页面的转场,例如详情页从右侧滑入;
  2. 匹配到的 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 匹配的形式化条件

设当前路由为 RsR_s,即 source route;即将进入的路由为 RdR_d,即 destination route。

对于每个路由,定义其中可参与匹配的 Hero 集合:

H(R)={(t,h)}H(R) = \{(t, h)\}

其中:

  • ttHero.tag
  • hh 是对应的 Hero
  • tag 的匹配使用 Dart 的相等关系,也就是 ==hashCode 语义,而不只是字符串内容。

一对 Hero 可以参与飞行,至少需要满足以下条件:

hsH(Rs),hdH(Rd):hs.tag==hd.tag\exists h_s \in H(R_s), h_d \in H(R_d): h_s.tag == h_d.tag

此外,在每一个参与匹配的路由中,同一个 tag 应该至多对应一个 Hero:

t,count(Rs,t)1\forall t,\quad count(R_s, t) \leq 1

t,count(Rd,t)1\forall t,\quad count(R_d, t) \leq 1

因此,下面的结构是有效的:

旧路由: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 转场究竟发生了什么

假设列表页中的缩略图位于矩形区域:

S=(xs,ys,ws,hs)S = (x_s, y_s, w_s, h_s)

详情页中的大图位于:

D=(xd,yd,wd,hd)D = (x_d, y_d, w_d, h_d)

其中:

  • x,yx, y 是相对于 Navigator 覆盖层坐标系的位置;
  • w,hw, h 是布局后的宽度和高度;
  • SS 是起始矩形;
  • DD 是目标矩形。

转场动画会产生一个进度值:

p[0,1]p \in [0, 1]

通常 p=0p=0 表示动画刚开始,p=1p=1 表示动画结束。矩形插值可以抽象为:

R(p)=Tween(S,D,p)R(p) = Tween(S, D, p)

如果使用简单线性插值,则:

x(p)=xs+(xdxs)px(p) = x_s + (x_d - x_s)p

y(p)=ys+(ydys)py(p) = y_s + (y_d - y_s)p

w(p)=ws+(wdws)pw(p) = w_s + (w_d - w_s)p

h(p)=hs+(hdhs)ph(p) = h_s + (h_d - h_s)p

实际 Flutter 实现可以使用 RectTween 的不同子类,让路径不是简单地同时线性改变四个边。例如 Material 风格可能使用弧线性质的矩形过渡。这里的关键不是某一种默认曲线,而是:Hero 的飞行几何来自起点矩形、终点矩形和路由动画进度

一次典型过程可以分成以下步骤:

  1. Navigator.push 开始,旧路由和新路由参与转场;
  2. Flutter 在两个路由的 Hero 子树中收集 tag;
  3. 找到相等的 tag;
  4. 读取起点和终点的布局矩形;
  5. 创建飞行中的 Hero;
  6. 飞行中的内容被放到 Navigator 的 overlay 中,因此可以暂时绘制在两个页面之上;
  7. 原来位置的 Hero 可以通过占位机制保留布局空间;
  8. 路由动画推进,飞行内容的位置和大小随进度变化;
  9. 动画结束后,飞行内容移除,目标路由中的 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 对象,输出是:

  1. 一个被压入 Navigator 的详情路由;
  2. 列表中的 photo-1 和详情中的 photo-1 被匹配;
  3. 图片从列表位置过渡到详情位置;
  4. 路由转场完成后显示详情页。

网络图片的风险是:如果目标页面构建时图片尚未加载,飞行中的视觉内容可能出现短暂占位或前后图像不一致。生产代码通常会使用缓存、预加载或明确的加载占位策略,但这些属于资源加载问题,不改变 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),
)

这里使用的是简单的 RectTweenbeginend 可能为空,因此不能无条件解包。自定义插值时还要意识到:矩形路径改变后,图片的缩放、裁剪和边界变化可能与页面转场曲线不协调。

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。它不应依赖已经失效的页面状态;如果使用 fromHeroContexttoHeroContext 查找上下文中的组件,需要考虑页面在动画中的生命周期和布局变化。

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: ...,
)

普通 pushpop 使用路由动画控制进度;交互式返回则可能由用户拖动手势控制进度。因此 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 为例:

  1. 路由创建并进入 Navigator;
  2. 新路由构建页面;
  3. 页面转场动画产生进度;
  4. Hero 使用相关动画进度更新自身位置;
  5. 页面背景、AppBar、列表等按照路由转场规则绘制;
  6. 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 进度不同步。

自定义转场的验证方式不是只看页面是否出现,而是分别验证:

  1. 页面转场是否按预期执行;
  2. Hero 是否被匹配;
  3. Hero 起点和终点是否正确;
  4. push 和 pop 是否都连续;
  5. 手势返回中是否仍然连续。

九、常见失败表现和诊断路径

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、裁剪区域或圆角在前后页面差异过大;
  • 源页面在飞行期间发生滚动或重排;
  • 占位组件没有保持原始尺寸。

诊断时可以先移除自定义的 flightShuttleBuilderplaceholderBuilder 和自定义矩形插值,使用最小 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

以下场景中,普通页面转场或自定义动画可能更准确:

  1. 前后页面中的对象没有明确的一一对应关系;
  2. 对象不是跨页面移动,而是页面整体展开;
  3. 两个 Navigator 之间需要跨边界共享动画;
  4. 动画需要复杂的物理效果、粒子效果或多个对象编排;
  5. 目标页面可能在转场开始时不存在稳定布局;
  6. 视觉连续性会让用户误以为两个不相关对象是同一个对象。

Hero 最适合表达:

列表项 → 详情项
头像 → 个人资料页头像
商品缩略图 → 商品详情图
卡片 → 展开后的同一张卡片

它不适合用来掩盖错误的路由数据、缺失的页面状态或不稳定的组件结构。


十五、一个可靠的排查顺序

当 Hero 不工作时,按以下因果顺序排查比直接修改动画参数更有效:

第一步:确认导航确实发生在同一个 Navigator

检查源页面和目标页面实际使用的 Navigator。嵌套 Navigator 是最常见的边界原因。

第二步:确认两侧都在转场时构建了 Hero

不要只检查源页面代码。目标页面必须在路由进入阶段构建包含相同 tag 的 Hero。

第三步:确认 tag 的相等性和唯一性

打印业务 ID 或 tag:

debugPrint('source tag: ${photo.id}');

检查两侧是否真正相等,并确认每个路由内没有重复值。

第四步:移除定制项验证基础机制

暂时移除:

  • flightShuttleBuilder
  • placeholderBuilder
  • createRectTween
  • 自定义页面转场

先验证最小的 Hero(tag: ..., child: ...) 是否能够飞行。

第五步:检查布局和状态生命周期

确认源、目标矩形在动画开始时都可测量,列表项没有因滚动或数据更新而消失,返回时目标页面没有被重新初始化成不同结构。

第六步:分别测试 push、pop 和交互式返回

push 能飞,不代表 pop 一定能飞;普通 pop 能飞,也不代表手势取消和反向动画没有问题。


总结

Hero 的核心不是“给 Widget 加一个动画标签”,而是让同一个 Navigator 管理的两个路由,在转场期间基于相等的 tag 建立视觉对应关系。

完整条件可以概括为:

有效 Hero 飞行=同一 Navigator两侧存在 Herotag 相等每侧 tag 唯一起点和终点可布局\text{有效 Hero 飞行} = \text{同一 Navigator} \land \text{两侧存在 Hero} \land \text{tag 相等} \land \text{每侧 tag 唯一} \land \text{起点和终点可布局}

匹配成功后,Flutter 会读取两个 Hero 的矩形,把飞行内容放入 Navigator overlay,并使用路由动画进度计算位置和尺寸。路由负责页面导航,页面转场负责整体页面变化,Hero 负责其中具有语义连续性的对象。

掌握这条数据流之后,tag 冲突、嵌套 Navigator、列表返回失败、图片闪烁、自定义 shuttle 和平台返回手势等问题,都可以还原为几个可验证的条件:谁在导航、谁被构建、tag 是否相等、矩形是否存在,以及飞行期间页面状态是否仍然稳定。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。