Skip to content

Hero 动画

Hero 动画实现两个页面之间共享元素的过渡效果——最常见的例子就是:在列表页点击一张小图片,图片"飞"到详情页变成大图。

一句话理解 Hero 动画

想象你在看漫画——主角从一格"嗖"地飞到下一格,虽然画面变了,但你知道那是同一个人。Hero 动画就是让两个页面的同一个元素"飞"过去的效果。

本章内容

  • Hero 动画的工作原理
  • 基本用法
  • 列表页 → 详情页的完整实现
  • 自定义 Hero 动画效果
  • 常见问题与注意事项

工作原理

Hero 动画的本质是:两个页面中,用相同 tag 标记的 Widget 之间做过渡动画

列表页                    飞行过程                    详情页
┌─────────────┐      ┌─────────────┐      ┌──────────────────────┐
│             │      │             │      │                      │
│   🖼️ 小图   │ ───→ │  🖼️ 飞行中  │ ───→ │     🖼️ 大图          │
│   tag: '1'  │      │             │      │     tag: '1'         │
│             │      │             │      │                      │
└─────────────┘      └─────────────┘      └──────────────────────┘
  页面 A(源)         系统自动生成           页面 B(目标)

当你从页面 A 跳转到页面 B 时,Flutter 做了三件事:

  1. 计算位置:找到页面 A 中 tag: '1' 的 Hero 的位置和大小
  2. 计算目标:找到页面 B 中 tag: '1' 的 Hero 的位置和大小
  3. 播放动画:在页面切换过程中,让这个元素从 A 的位置/大小平滑过渡到 B 的位置/大小

你只需要在两个页面各放一个 Hero,设置相同的 tag,剩下的 Flutter 自动完成。

最简单的例子

页面 A(源页面)

dart
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(目标页面)

dart
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

数据模型

dart
class Product {
  final String id;
  final String name;
  final String imageUrl;

  const Product({required this.id, required this.name, required this.imageUrl});
}

列表页

dart
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),
        ],
      ),
    );
  }
}

详情页

dart
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 自定义飞行过程中的样式:

dart
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 — 自定义飞行路径

默认飞行路径是直线,你可以改成弧线:

dart
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 自定义占位内容:

dart
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 动画参数速查

参数类型必填说明
tagObject唯一标识,两个页面必须一致
childWidget要做动画的子组件
createRectTweenCreateRectTween?自定义飞行路径
flightShuttleBuilderFlightShuttleBuilder?自定义飞行中的 Widget
placeholderBuilderPlaceholderBuilder?自定义源位置占位
transitionOnUserGesturesbooliOS 手势返回时是否执行 Hero 动画,默认 false

完整示例:商品列表 Hero 跳转

dart
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:

dart
// ❌ 错误:所有项都用相同的 tag
Hero(tag: 'product', ...)

// ✅ 正确:每项用唯一 tag
Hero(tag: 'product-${product.id}', ...)

2. Hero 动画没有效果

常见原因

  1. 两个 tag 不一致——检查拼写、大小写
  2. 没有用 Navigator 跳转——Hero 动画只在页面切换(Navigator.push/pop)时生效,直接切换 Widget 不会触发
  3. tag 包含特殊字符——tag 可以是任意 Object,但建议只用 String 或 int

3. 两个 Hero 的 child 差异太大,动画不好看

Hero 动画的过渡效果依赖于"形状插值"。如果源和目标的形状差异太大(比如一个是圆形、一个是全宽矩形),过渡可能看起来很奇怪。

建议:两个 Hero 的 child 最好是同类型的 Widget(都是图片、都是图标等),且形状差异不要太大。

4. iOS 侧滑返回时 Hero 动画不生效

需要设置 transitionOnUserGestures: true

dart
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 中做耗时操作

基于 Flutter 官方文档整理