Flutter Hero 动画完全指南

引言

Hero 动画是 Flutter 中实现页面间元素过渡的强大工具,它允许元素在页面跳转时平滑地从一个页面"飞"到另一个页面。本文将深入探讨 Hero 动画的各种用法和高级技巧。

基础概念回顾

什么是 Hero 动画

Hero 动画是一种共享元素过渡效果,它让同一个 Widget 在两个页面之间平滑过渡。

基本语法

// 源页面
Hero(
  tag: 'hero-tag',
  child: Image.network('image.jpg'),
)

// 目标页面
Hero(
  tag: 'hero-tag',
  child: Image.network('image.jpg'),
)

高级技巧一:基本 Hero 动画

创建简单的 Hero 动画

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('首页')),
      body: Center(
        child: Hero(
          tag: 'avatar',
          child: GestureDetector(
            onTap: () {
              Navigator.push(
                context,
                MaterialPageRoute(builder: (_) => DetailPage()),
              );
            },
            child: CircleAvatar(
              backgroundImage: NetworkImage('https://example.com/avatar.jpg'),
              radius: 50,
            ),
          ),
        ),
      ),
    );
  }
}

class DetailPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('详情页')),
      body: Center(
        child: Hero(
          tag: 'avatar',
          child: CircleAvatar(
            backgroundImage: NetworkImage('https://example.com/avatar.jpg'),
            radius: 100,
          ),
        ),
      ),
    );
  }
}

自定义过渡效果

Hero(
  tag: 'custom-hero',
  createRectTween: (begin, end) {
    return CustomRectTween(begin: begin, end: end);
  },
  child: MyWidget(),
)

高级技巧二:Hero 动画样式

添加圆角

Hero(
  tag: 'rounded-image',
  child: ClipRRect(
    borderRadius: BorderRadius.circular(16),
    child: Image.network('image.jpg'),
  ),
)

添加阴影

Hero(
  tag: 'shadow-hero',
  child: Container(
    decoration: BoxDecoration(
      boxShadow: [
        BoxShadow(
          color: Colors.black26,
          blurRadius: 8,
          offset: Offset(2, 2),
        ),
      ],
    ),
    child: Image.network('image.jpg'),
  ),
)

高级技巧三:多个 Hero 动画

并行过渡多个元素

// 源页面
Row(
  children: [
    Hero(
      tag: 'image-hero',
      child: Image.network('image.jpg'),
    ),
    Hero(
      tag: 'text-hero',
      child: Text('标题'),
    ),
  ],
)

// 目标页面
Column(
  children: [
    Hero(
      tag: 'image-hero',
      child: Image.network('image.jpg'),
    ),
    Hero(
      tag: 'text-hero',
      child: Text('标题'),
    ),
  ],
)

高级技巧四:Hero 动画控制器

使用 HeroController

MaterialApp(
  home: MyHomePage(),
  heroController: HeroController(
    createRectTween: (begin, end) {
      return MaterialRectArcTween(begin: begin, end: end);
    },
  ),
)

自定义过渡曲线

Hero(
  tag: 'curve-hero',
  flightShuttleBuilder: (
    BuildContext flightContext,
    Animation<double> animation,
    HeroFlightDirection flightDirection,
    BuildContext fromHeroContext,
    BuildContext toHeroContext,
  ) {
    return ScaleTransition(
      scale: animation.drive(
        CurveTween(curve: Curves.easeInOut),
      ),
      child: Image.network('image.jpg'),
    );
  },
  child: Image.network('image.jpg'),
)

实战案例:图片画廊

class GalleryPage extends StatelessWidget {
  final List<String> images = [
    'https://example.com/img1.jpg',
    'https://example.com/img2.jpg',
    'https://example.com/img3.jpg',
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('图片画廊')),
      body: GridView.builder(
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2,
        ),
        itemCount: images.length,
        itemBuilder: (context, index) {
          return GestureDetector(
            onTap: () {
              Navigator.push(
                context,
                PageRouteBuilder(
                  pageBuilder: (_, __, ___) => DetailImagePage(
                    imageUrl: images[index],
                    index: index,
                  ),
                ),
              );
            },
            child: Hero(
              tag: 'image-$index',
              child: Image.network(
                images[index],
                fit: BoxFit.cover,
              ),
            ),
          );
        },
      ),
    );
  }
}

class DetailImagePage extends StatelessWidget {
  final String imageUrl;
  final int index;

  DetailImagePage({required this.imageUrl, required this.index});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: Center(
        child: Hero(
          tag: 'image-$index',
          child: Image.network(imageUrl),
        ),
      ),
    );
  }
}

实战案例:卡片详情页

class CardListPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('卡片列表')),
      body: ListView.builder(
        itemCount: 10,
        itemBuilder: (context, index) {
          return Card(
            child: ListTile(
              leading: Hero(
                tag: 'avatar-$index',
                child: CircleAvatar(
                  child: Text('${index + 1}'),
                ),
              ),
              title: Hero(
                tag: 'title-$index',
                child: Text('卡片 ${index + 1}'),
              ),
              onTap: () {
                Navigator.push(
                  context,
                  MaterialPageRoute(
                    builder: (_) => CardDetailPage(index: index),
                  ),
                );
              },
            ),
          );
        },
      ),
    );
  }
}

class CardDetailPage extends StatelessWidget {
  final int index;

  CardDetailPage({required this.index});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Hero(tag: 'title-$index', child: Text('卡片 ${index + 1}'))),
      body: Center(
        child: Hero(
          tag: 'avatar-$index',
          child: CircleAvatar(
            radius: 80,
            child: Text('${index + 1}'),
          ),
        ),
      ),
    );
  }
}

实战案例:Hero 动画与页面路由

class CustomHeroRoute extends PageRouteBuilder {
  final Widget page;

  CustomHeroRoute({required this.page})
      : super(
          pageBuilder: (context, animation, secondaryAnimation) => page,
          transitionsBuilder: (context, animation, secondaryAnimation, child) {
            return FadeTransition(
              opacity: animation,
              child: child,
            );
          },
        );
}

// 使用
Navigator.push(
  context,
  CustomHeroRoute(page: DetailPage()),
);

常见问题与解决方案

Q1:Hero 动画不生效?

A:确保两个页面的 Hero tag 相同:

// 正确
Hero(tag: 'same-tag', child: ...)

// 错误
Hero(tag: 'tag1', child: ...) // 源页面
Hero(tag: 'tag2', child: ...) // 目标页面

Q2:如何自定义过渡路径?

A:使用 createRectTween:

Hero(
  tag: 'custom-path',
  createRectTween: (begin, end) {
    return MaterialRectCenterArcTween(begin: begin, end: end);
  },
  child: ...,
)

Q3:如何在 Hero 动画期间显示遮罩?

A:使用 flightShuttleBuilder:

Hero(
  tag: 'mask-hero',
  flightShuttleBuilder: (context, animation, flightDirection, from, to) {
    return Material(
      color: Colors.black54,
      child: Image.network('image.jpg'),
    );
  },
  child: Image.network('image.jpg'),
)

最佳实践

1. 使用唯一的 tag

// 推荐
Hero(tag: 'user-avatar-${user.id}', child: ...)

// 不推荐
Hero(tag: 'avatar', child: ...) // 多个元素使用相同 tag

2. 保持子 Widget 类型一致

// 推荐
Hero(tag: 'image', child: Image.network('url')) // 两边都是 Image

// 不推荐
Hero(tag: 'image', child: Image.network('url')) // 源页面
Hero(tag: 'image', child: Container()) // 目标页面

3. 避免复杂的 Hero 子 Widget

// 推荐
Hero(
  tag: 'simple-hero',
  child: Image.network('url'), // 简单 Widget
)

// 不推荐
Hero(
  tag: 'complex-hero',
  child: Container(
    child: Column(
      children: [...], // 复杂嵌套
    ),
  ),
)

总结

Flutter 的 Hero 动画是创建流畅页面过渡的强大工具。通过本文的学习,你应该能够:

  1. 创建基本的 Hero 动画
  2. 实现多个元素的并行过渡
  3. 自定义过渡效果和路径
  4. 处理常见问题
  5. 遵循最佳实践

掌握这些技巧,能够帮助你创建更加吸引人的用户体验。

更多推荐