🦸 27 - Hero动画与转场

阶段四:高级UI · 第27课
✅ 验证通过

📖 核心概念

Hero动画是Flutter最优雅的转场效果——共享元素在两个页面间平滑飞行,创造出令人惊叹的视觉连续性。本课深入Hero动画原理,从基础用法到自定义飞行路径,再到多元素同步过渡,让你的App拥有顶级应用般的交互体验。

🦸 Hero动画基础

1. 最简Hero动画

Hero动画的核心非常简单:在两个页面中,用Hero Widget包裹同一个元素,给它们相同的tag,Flutter自动计算飞入/飞出动画。

// 列表页 - 用Hero包裹图片
class PhotoListPage extends StatelessWidget {
  final List<Photo> photos;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('相册')),
      body: GridView.builder(
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 3, crossAxisSpacing: 2, mainAxisSpacing: 2,
        ),
        itemCount: photos.length,
        itemBuilder: (context, index) {
          return GestureDetector(
            onTap: () => Navigator.push(context,
              MaterialPageRoute(builder: (_) => PhotoDetailPage(photo: photos[index]))),
            child: Hero(
              tag: 'photo_${photos[index].id}',  // 唯一标识
              child: Image.network(photos[index].thumbnailUrl, fit: BoxFit.cover),
            ),
          );
        },
      ),
    );
  }
}

// 详情页 - 同样用Hero包裹,相同的tag
class PhotoDetailPage extends StatelessWidget {
  final Photo photo;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Hero(
          tag: 'photo_${photo.id}',  // 与列表页相同的tag
          child: Image.network(photo.fullUrl, fit: BoxFit.contain),
        ),
      ),
    );
  }
}
💡 Hero动画原理:Flutter在路由切换时,会查找源页面和目标页面中tag相同的Hero Widget,创建一个Overlay层,在两个Hero的位置和大小之间执行动画。源Hero淡出,目标Hero淡入,Overlay层执行位移动画。

2. 自定义飞行动画

// 使用flightShuttleBuilder自定义飞行过程的外观
Hero(
  tag: 'photo_${photo.id}',
  flightShuttleBuilder: (flightContext, animation, flightDirection,
      fromHeroContext, toHeroContext) {
    // 根据飞行方向选择不同的动画效果
    final isForward = flightDirection == HeroFlightDirection.push;

    return AnimatedBuilder(
      animation: animation,
      builder: (context, child) {
        return Opacity(
          opacity: isForward ? animation.value : 1 - animation.value,
          child: Transform.scale(
            scale: 0.8 + 0.2 * animation.value,
            child: Transform.rotate(
              angle: isForward ? (1 - animation.value) * 0.1 : 0,
              child: ClipRRect(
                borderRadius: BorderRadius.circular(
                  16 * animation.value,
                ),
                child: child,
              ),
            ),
          ),
        );
      },
      child: isForward
        ? toHeroContext.widget
        : fromHeroContext.widget,
    );
  },
  child: Image.network(photo.thumbnailUrl, fit: BoxFit.cover),
)

3. 多Hero同步过渡

一个页面可以有多个Hero同时过渡——例如商品列表中的商品图片和标题同步飞入详情页。

// 列表项 - 两个Hero元素
Widget buildProductItem(Product product) {
  return GestureDetector(
    onTap: () => Navigator.push(context,
      MaterialPageRoute(builder: (_) => ProductDetailPage(product: product))),
    child: Column(
      children: [
        Hero(
          tag: 'product_image_${product.id}',
          child: Image.network(product.imageUrl, height: 150, fit: BoxFit.cover),
        ),
        Hero(
          tag: 'product_name_${product.id}',
          child: Material(
            color: Colors.transparent,
            child: Text(product.name,
              style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
          ),
        ),
        Hero(
          tag: 'product_price_${product.id}',
          child: Material(
            color: Colors.transparent,
            child: Text('¥${product.price}',
              style: TextStyle(fontSize: 18, color: Colors.red[700])),
          ),
        ),
      ],
    ),
  );
}

// 详情页 - 匹配的Hero元素
class ProductDetailPage extends StatelessWidget {
  final Product product;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          SliverAppBar(
            expandedHeight: 300,
            flexibleSpace: FlexibleSpaceBar(
              background: Hero(
                tag: 'product_image_${product.id}',
                child: Image.network(product.imageUrl, fit: BoxFit.cover),
              ),
            ),
          ),
          SliverToBoxAdapter(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Hero(
                    tag: 'product_name_${product.id}',
                    child: Material(
                      color: Colors.transparent,
                      child: Text(product.name,
                        style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
                    ),
                  ),
                  const SizedBox(height: 8),
                  Hero(
                    tag: 'product_price_${product.id}',
                    child: Material(
                      color: Colors.transparent,
                      child: Text('¥${product.price}',
                        style: TextStyle(fontSize: 28, color: Colors.red[700])),
                    ),
                  ),
                  const SizedBox(height: 16),
                  Text(product.description),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

4. Hero与PageView联动

// 在PageView中使用Hero - 需要确保tag唯一
class GalleryPage extends StatefulWidget {
  final List<Photo> photos;
  final int initialIndex;

  @override
  State<GalleryPage> createState() => _GalleryPageState();
}

class _GalleryPageState extends State<GalleryPage> {
  late PageController _pageController;

  @override
  void initState() {
    super.initState();
    _pageController = PageController(initialPage: widget.initialIndex);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: PageView.builder(
        controller: _pageController,
        itemCount: widget.photos.length,
        itemBuilder: (context, index) {
          final photo = widget.photos[index];
          return Center(
            child: Hero(
              // 关键:使用当前页的tag,不是初始页
              tag: 'photo_${photo.id}',
              child: Image.network(photo.fullUrl, fit: BoxFit.contain),
            ),
          );
        },
      ),
    );
  }
}
⚠️ Hero动画常见问题:

🎭 Hero动画进阶技巧

5. 嵌套Hero与SubHero

在列表项中嵌入Hero时,可能会遇到子Hero与父Hero冲突的问题。使用NestedHero模式解决。

// 嵌套Hero解决方案
// 问题:列表页Hero到详情页,但详情页内又有子Hero(如详情页内图片轮播)

class NestedHeroPage extends StatelessWidget {
  final Product product;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          // 主Hero - 商品图片
          SliverAppBar(
            expandedHeight: 300,
            flexibleSpace: FlexibleSpaceBar(
              background: Hero(
                tag: 'product_image_${product.id}',
                child: PageView.builder(
                  itemCount: product.images.length,
                  itemBuilder: (context, index) {
                    // 子图片不使用Hero,避免冲突
                    return Image.network(product.images[index], fit: BoxFit.cover);
                  },
                ),
              ),
            ),
          ),
          // ...
        ],
      ),
    );
  }
}

// 方案2:使用HeroController自定义Hero行为
class CustomHeroController extends HeroController {
  @override
  Map<Type, GestureRecognizerFactory> createGestureRecognizerFactories() {
    return {};  // 禁用默认手势,自定义过渡逻辑
  }
}

6. Hero与TabBarView配合

在TabBarView中使用Hero需要特殊处理——因为多个tab的Hero可能同时存在。

// TabBarView中的Hero
// 关键:使用动态tag,确保每个tab只有当前可见页的Hero参与动画
class TabbedGallery extends StatefulWidget {
  final List<PhotoCategory> categories;

  @override
  State<TabbedGallery> createState() => _TabbedGalleryState();
}

class _TabbedGalleryState extends State<TabbedGallery>
    with SingleTickerProviderStateMixin {
  late TabController _tabController;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(
      length: widget.categories.length, vsync: this,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: TabBar(
        controller: _tabController,
        tabs: widget.categories.map((c) => Tab(text: c.name)).toList(),
      ),
      body: TabBarView(
        controller: _tabController,
        children: widget.categories.map((category) {
          return GridView.builder(
            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 3,
            ),
            itemCount: category.photos.length,
            itemBuilder: (context, index) {
              final photo = category.photos[index];
              return GestureDetector(
                onTap: () => Navigator.push(context,
                  MaterialPageRoute(builder: (_) => PhotoDetailPage(
                    photo: photo,
                    categoryTag: category.id, // 分类+ID确保唯一
                  )),
                ),
                child: Hero(
                  // 关键:tag必须全局唯一
                  tag: '${category.id}_photo_${photo.id}',
                  child: Image.network(photo.thumbnail, fit: BoxFit.cover),
                ),
              );
            },
          );
        }).toList(),
      ),
    );
  }
}

7. 创建自定义路由转场组合

将Hero动画与其他转场效果(缩放、滑动、淡入)组合,打造更丰富的视觉体验。

// 组合转场动画
class CombinedPageTransition<T> extends PageRouteBuilder<T> {
  final Widget page;

  CombinedPageTransition({required this.page})
    : super(
        pageBuilder: (context, animation, secondaryAnimation) => page,
        transitionDuration: const Duration(milliseconds: 500),
        reverseTransitionDuration: const Duration(milliseconds: 400),
        transitionsBuilder: (context, animation, secondaryAnimation, child) {
          // 滑动 + 淡入 + 缩放
          final slideAnimation = Tween<Offset>(
            begin: const Offset(0.1, 0),
            end: Offset.zero,
          ).animate(CurvedAnimation(
            parent: animation,
            curve: Curves.easeOutCubic,
          ));

          final fadeAnimation = Tween<double>(
            begin: 0.0, end: 1.0,
          ).animate(CurvedAnimation(
            parent: animation,
            curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
          ));

          final scaleAnimation = Tween<double>(
            begin: 0.95, end: 1.0,
          ).animate(CurvedAnimation(
            parent: animation,
            curve: Curves.easeOutCubic,
          ));

          return SlideTransition(
            position: slideAnimation,
            child: FadeTransition(
              opacity: fadeAnimation,
              child: ScaleTransition(
                scale: scaleAnimation,
                child: child,
              ),
            ),
          );
        },
      );
}

// 旧页面的退出动画
// secondaryAnimation控制旧页面:
// 1. 淡出 + 轻微缩小
// 2. 向左轻微滑动
// 这些已由Flutter默认处理,但可以自定义

📝 练习

  1. 实现圆形头像Hero——从圆形小头像飞到详情页的大方形图片(提示:ClipOval → ClipRect过渡)
  2. 创建自定义路由转场——结合Hero动画+页面淡入淡出+滑动效果
  3. 实现Hero动画的弹性曲线——使用Curves.elasticOut让飞行更有弹性
  4. 在列表页和地图页之间实现Hero——标记点从列表飞到地图上的位置

🏆 成就解锁:动画导演

你已掌握Hero动画与页面转场!

下一课预告:自定义绘制与Canvas——用代码画出任意图形,突破Widget的极限!