Flutter Hero 动画完全指南

引言

Hero 动画是 Flutter 中实现页面间过渡的强大工具。本文将深入探讨 Hero 动画的各种用法和高级技巧。

基础概念回顾

Hero 动画原理

  • Hero Widget: 标记要共享的 widget
  • Flight Route: 定义过渡路径
  • HeroController: 控制动画过程

基本语法

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

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

高级技巧一:基础 Hero 动画

简单图片过渡

// FirstScreen
class FirstScreen extends StatelessWidget {
  const FirstScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('First Screen')),
      body: Center(
        child: Hero(
          tag: 'imageHero',
          child: GestureDetector(
            onTap: () {
              Navigator.push(
                context,
                MaterialPageRoute(builder: (_) => const SecondScreen()),
              );
            },
            child: Image.network(
              'https://picsum.photos/250?image=9',
              width: 100,
              height: 100,
            ),
          ),
        ),
      ),
    );
  }
}

// SecondScreen
class SecondScreen extends StatelessWidget {
  const SecondScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Second Screen')),
      body: Center(
        child: Hero(
          tag: 'imageHero',
          child: Image.network('https://picsum.photos/250?image=9'),
        ),
      ),
    );
  }
}

自定义过渡效果

Hero(
  tag: 'customHero',
  createRectTween: (begin, end) {
    return MaterialRectCenterArcTween(begin: begin, end: end);
  },
  child: const Icon(Icons.star, size: 50),
)

高级技巧二:Hero 动画类型

矩形过渡

Hero(
  tag: 'rectHero',
  child: Container(
    width: 100,
    height: 100,
    color: Colors.blue,
  ),
)

圆形过渡

Hero(
  tag: 'circleHero',
  child: const CircleAvatar(
    backgroundImage: NetworkImage('https://picsum.photos/250?image=9'),
    radius: 50,
  ),
)

任意形状过渡

Hero(
  tag: 'shapeHero',
  child: ClipPath(
    clipper: StarClipper(),
    child: Container(
      width: 100,
      height: 100,
      color: Colors.orange,
    ),
  ),
)

高级技巧三:Hero 动画参数

飞行速度

Hero(
  tag: 'slowHero',
  flightShuttleBuilder: (
    BuildContext flightContext,
    Animation<double> animation,
    HeroFlightDirection flightDirection,
    BuildContext fromHeroContext,
    BuildContext toHeroContext,
  ) {
    return ScaleTransition(
      scale: animation.drive(Tween<double>(begin: 1.0, end: 1.5)),
      child: const Icon(Icons.flight, size: 50),
    );
  },
  child: const Icon(Icons.flight, size: 50),
)

自定义过渡路径

Hero(
  tag: 'arcHero',
  createRectTween: (begin, end) {
    return MaterialRectArcTween(begin: begin, end: end);
  },
  child: const Icon(Icons.arrow_upward, size: 50),
)

高级技巧四:多个 Hero 动画

同步动画

// FirstScreen
Row(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [
    Hero(
      tag: 'hero1',
      child: Container(width: 50, height: 50, color: Colors.red),
    ),
    const SizedBox(width: 20),
    Hero(
      tag: 'hero2',
      child: Container(width: 50, height: 50, color: Colors.blue),
    ),
  ],
)

// SecondScreen
Column(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [
    Hero(
      tag: 'hero1',
      child: Container(width: 100, height: 100, color: Colors.red),
    ),
    const SizedBox(height: 20),
    Hero(
      tag: 'hero2',
      child: Container(width: 100, height: 100, color: Colors.blue),
    ),
  ],
)

实战案例:图片详情页

class GalleryScreen extends StatelessWidget {
  const GalleryScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Gallery')),
      body: GridView.builder(
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 3,
          crossAxisSpacing: 8,
          mainAxisSpacing: 8,
        ),
        itemCount: 9,
        itemBuilder: (context, index) {
          return GestureDetector(
            onTap: () {
              Navigator.push(
                context,
                PageRouteBuilder(
                  pageBuilder: (_, __, ___) => DetailScreen(index: index),
                  transitionsBuilder: (_, animation, __, child) {
                    return FadeTransition(opacity: animation, child: child);
                  },
                ),
              );
            },
            child: Hero(
              tag: 'image-$index',
              child: Image.network(
                'https://picsum.photos/200/200?image=$index',
                fit: BoxFit.cover,
              ),
            ),
          );
        },
      ),
    );
  }
}

class DetailScreen extends StatelessWidget {
  final int index;

  const DetailScreen({super.key, required this.index});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: Center(
        child: Hero(
          tag: 'image-$index',
          child: Image.network(
            'https://picsum.photos/600/600?image=$index',
            fit: BoxFit.contain,
          ),
        ),
      ),
    );
  }
}

实战案例:卡片展开动画

class CardScreen extends StatelessWidget {
  const CardScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Cards')),
      body: ListView.builder(
        itemCount: 5,
        itemBuilder: (context, index) {
          return CardItem(index: index);
        },
      ),
    );
  }
}

class CardItem extends StatelessWidget {
  final int index;

  const CardItem({super.key, required this.index});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        Navigator.push(
          context,
          MaterialPageRoute(builder: (_) => CardDetail(index: index)),
        );
      },
      child: Hero(
        tag: 'card-$index',
        child: Card(
          elevation: 4,
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              children: [
                Container(
                  width: 60,
                  height: 60,
                  color: Colors.blue,
                ),
                const SizedBox(width: 16),
                Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: const [
                    Text('Card Title'),
                    Text('Card description'),
                  ],
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class CardDetail extends StatelessWidget {
  final int index;

  const CardDetail({super.key, required this.index});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Card Detail')),
      body: Hero(
        tag: 'card-$index',
        child: Card(
          elevation: 8,
          margin: const EdgeInsets.all(16),
          child: Padding(
            padding: const EdgeInsets.all(32),
            child: Column(
              children: [
                Container(
                  width: 200,
                  height: 200,
                  color: Colors.blue,
                ),
                const SizedBox(height: 24),
                const Text('Card Title', style: TextStyle(fontSize: 24)),
                const SizedBox(height: 16),
                const Text(
                  'This is the detailed description of the card. It contains more information about the content.',
                  style: TextStyle(fontSize: 16),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

实战案例:共享元素导航

class ListScreen extends StatelessWidget {
  const ListScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Items')),
      body: ListView(
        children: const [
          ListItem(title: 'Item 1', subtitle: 'Description 1'),
          ListItem(title: 'Item 2', subtitle: 'Description 2'),
          ListItem(title: 'Item 3', subtitle: 'Description 3'),
        ],
      ),
    );
  }
}

class ListItem extends StatelessWidget {
  final String title;
  final String subtitle;

  const ListItem({super.key, required this.title, required this.subtitle});

  @override
  Widget build(BuildContext context) {
    return ListTile(
      leading: Hero(
        tag: 'icon-$title',
        child: const Icon(Icons.star),
      ),
      title: Hero(
        tag: 'title-$title',
        child: Material(
          type: MaterialType.transparency,
          child: Text(title),
        ),
      ),
      subtitle: Text(subtitle),
      onTap: () {
        Navigator.push(
          context,
          MaterialPageRoute(builder: (_) => DetailScreen(title: title)),
        );
      },
    );
  }
}

class DetailScreen extends StatelessWidget {
  final String title;

  const DetailScreen({super.key, required this.title});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        leading: Hero(
          tag: 'icon-$title',
          child: const Icon(Icons.star),
        ),
        title: Hero(
          tag: 'title-$title',
          child: Text(title),
        ),
      ),
      body: const Center(child: Text('Detail content')),
    );
  }
}

常见问题与解决方案

Q1:Hero 动画不生效?

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

Hero(
  tag: 'same-tag',
  child: ...,
)

Q2:Hero 重叠问题?

A:使用不同的 tag:

Hero(
  tag: 'unique-tag-${item.id}',
  child: ...,
)

Q3:动画卡顿?

A:避免在 Hero 中使用复杂 widget:

Hero(
  tag: 'simple-hero',
  child: Container(width: 50, height: 50, color: Colors.blue),
)

最佳实践

1. 使用唯一的 tag

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

2. 保持 widget 类型一致

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

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

3. 使用 Material 包装文本

Hero(
  tag: 'text-hero',
  child: Material(
    type: MaterialType.transparency,
    child: Text('Hello'),
  ),
)

总结

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

  1. 创建基础 Hero 动画
  2. 使用不同类型的过渡
  3. 自定义动画参数
  4. 实现多个 Hero 动画
  5. 创建图片详情页过渡
  6. 实现卡片展开效果

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

更多推荐