Flutter Hero 动画:创建无缝的页面过渡效果
Flutter Hero 动画创建无缝的页面过渡效果代码如诗动画如歌。让我们用 Hero 动画的魔法为用户带来流畅而惊艳的页面过渡体验。什么是 Hero 动画Hero 动画是 Flutter 中一种特殊的动画效果它可以在两个页面之间创建无缝的元素过渡。当用户从一个页面导航到另一个页面时Hero 动画会让共享的元素平滑地从源位置动画到目标位置创造出一种连贯的视觉体验。Hero 动画的核心概念1. Hero WidgetHero是 Flutter 中实现 Hero 动画的核心 Widget。它包裹需要动画的元素并通过tag属性来标识共享的元素。Hero( tag: image-hero, child: Image.network(https://example.com/image.jpg), )2. Tag 标识tag是 Hero 动画的关键它用于在两个页面之间匹配对应的 Hero Widget。相同的 tag 表示同一个元素。3. 页面路由Hero 动画需要配合页面路由使用通常是Navigator.push()和Navigator.pop()。基本实现示例 1图片详情页过渡import package:flutter/material.dart; // 列表页面 class PhotoListPage extends StatelessWidget { const PhotoListPage({super.key}); final ListString photos const [ https://picsum.photos/400/300?random1, https://picsum.photos/400/300?random2, https://picsum.photos/400/300?random3, https://picsum.photos/400/300?random4, https://picsum.photos/400/300?random5, ]; override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text(照片列表)), body: GridView.builder( padding: const EdgeInsets.all(16), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, crossAxisSpacing: 16, mainAxisSpacing: 16, ), itemCount: photos.length, itemBuilder: (context, index) { return GestureDetector( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) PhotoDetailPage( photoUrl: photos[index], tag: photo-$index, ), ), ); }, child: Hero( tag: photo-$index, child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.network( photos[index], fit: BoxFit.cover, ), ), ), ); }, ), ); } } // 详情页面 class PhotoDetailPage extends StatelessWidget { final String photoUrl; final String tag; const PhotoDetailPage({ super.key, required this.photoUrl, required this.tag, }); override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, body: GestureDetector( onTap: () Navigator.pop(context), child: Center( child: Hero( tag: tag, child: Image.network( photoUrl, fit: BoxFit.contain, ), ), ), ), ); } }示例 2卡片到详情页过渡import package:flutter/material.dart; // 商品模型 class Product { final String id; final String name; final String imageUrl; final double price; final String description; Product({ required this.id, required this.name, required this.imageUrl, required this.price, required this.description, }); } // 商品列表页面 class ProductListPage extends StatelessWidget { const ProductListPage({super.key}); final ListProduct products const [ Product( id: 1, name: 无线耳机, imageUrl: https://picsum.photos/300/300?random1, price: 299.0, description: 高品质无线耳机提供出色的音质体验, ), Product( id: 2, name: 智能手表, imageUrl: https://picsum.photos/300/300?random2, price: 599.0, description: 多功能智能手表监测健康数据, ), Product( id: 3, name: 蓝牙音箱, imageUrl: https://picsum.photos/300/300?random3, price: 199.0, description: 便携式蓝牙音箱音质出众, ), ]; 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 ProductCard(product: product); }, ), ); } } // 商品卡片 class ProductCard extends StatelessWidget { final Product product; const ProductCard({super.key, required this.product}); override Widget build(BuildContext context) { return Card( margin: const EdgeInsets.only(bottom: 16), child: InkWell( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) ProductDetailPage(product: product), ), ); }, child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Hero( tag: product-image-${product.id}, child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network( product.imageUrl, width: 100, height: 100, fit: BoxFit.cover, ), ), ), const SizedBox(width: 16), Expanded( 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: 18, 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: 16, color: Theme.of(context).primaryColor, fontWeight: FontWeight.w600, ), ), ), ), ], ), ), ], ), ), ), ); } } // 商品详情页面 class ProductDetailPage extends StatelessWidget { final Product product; const ProductDetailPage({super.key, required this.product}); override Widget build(BuildContext context) { return Scaffold( body: CustomScrollView( slivers: [ SliverAppBar( expandedHeight: 300, pinned: true, 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: 16), Hero( tag: product-price-${product.id}, child: Material( color: Colors.transparent, child: Text( ¥${product.price}, style: TextStyle( fontSize: 28, color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold, ), ), ), ), const SizedBox(height: 24), Text( product.description, style: const TextStyle( fontSize: 16, color: Colors.grey, height: 1.5, ), ), ], ), ), ), ], ), ); } }高级技巧1. 自定义 Hero 动画Hero( tag: custom-hero, flightShuttleBuilder: ( BuildContext flightContext, Animationdouble animation, HeroFlightDirection flightDirection, BuildContext fromHeroContext, BuildContext toHeroContext, ) { return AnimatedBuilder( animation: animation, builder: (context, child) { return Transform.rotate( angle: animation.value * 3.14, child: child, ); }, child: toHeroContext.widget, ); }, child: Container( width: 100, height: 100, color: Colors.blue, ), )2. 占位符动画Hero( tag: placeholder-hero, placeholderBuilder: (context, heroSize, child) { return Container( width: heroSize.width, height: heroSize.height, color: Colors.grey[300], child: const Center( child: CircularProgressIndicator(), ), ); }, child: Image.network(https://example.com/large-image.jpg), )3. 多个 Hero 动画组合// 在列表页面 Column( children: [ Hero( tag: header-$index, child: Image.network(photos[index]), ), Hero( tag: title-$index, child: Text(标题 $index), ), Hero( tag: description-$index, child: Text(描述 $index), ), ], ) // 在详情页面 Column( children: [ Hero( tag: header-$index, child: Image.network(photos[index]), ), Hero( tag: title-$index, child: Text(标题 $index), ), Hero( tag: description-$index, child: Text(描述 $index), ), ], )4. 使用 PageRouteBuilder 自定义过渡Navigator.push( context, PageRouteBuilder( pageBuilder: (context, animation, secondaryAnimation) { return PhotoDetailPage(photoUrl: photoUrl, tag: tag); }, transitionsBuilder: (context, animation, secondaryAnimation, child) { return FadeTransition( opacity: animation, child: child, ); }, transitionDuration: const Duration(milliseconds: 500), ), );最佳实践使用唯一的 Tag确保每个 Hero 的 tag 在应用中是唯一的避免冲突。保持视觉一致性Hero 动画的两端应该保持视觉一致性避免突兀的过渡。适当的动画时长动画时长应该适中通常在 300-500 毫秒之间。避免过多的 Hero 动画过多的 Hero 动画可能会让用户感到混乱应该谨慎使用。测试不同屏幕尺寸确保 Hero 动画在不同屏幕尺寸上都能正常工作。实践案例创建一个图片画廊应用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: 图片画廊, theme: ThemeData(primarySwatch: Colors.blue), home: const GalleryPage(), ); } } // 图片数据 class Photo { final String id; final String url; final String title; final String photographer; Photo({ required this.id, required this.url, required this.title, required this.photographer, }); } final ListPhoto photos [ Photo( id: 1, url: https://picsum.photos/800/600?random1, title: 山间晨雾, photographer: 张三, ), Photo( id: 2, url: https://picsum.photos/800/600?random2, title: 城市夜景, photographer: 李四, ), Photo( id: 3, url: https://picsum.photos/800/600?random3, title: 海边日落, photographer: 王五, ), Photo( id: 4, url: https://picsum.photos/800/600?random4, title: 森林小径, photographer: 赵六, ), Photo( id: 5, url: https://picsum.photos/800/600?random5, title: 雪山倒影, photographer: 钱七, ), Photo( id: 6, url: https://picsum.photos/800/600?random6, title: 花海盛开, photographer: 孙八, ), ]; // 画廊页面 class GalleryPage extends StatelessWidget { const GalleryPage({super.key}); override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text(图片画廊), centerTitle: true, ), body: GridView.builder( padding: const EdgeInsets.all(16), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, crossAxisSpacing: 16, mainAxisSpacing: 16, childAspectRatio: 1, ), itemCount: photos.length, itemBuilder: (context, index) { final photo photos[index]; return PhotoCard(photo: photo, index: index); }, ), ); } } // 图片卡片 class PhotoCard extends StatelessWidget { final Photo photo; final int index; const PhotoCard({ super.key, required this.photo, required this.index, }); override Widget build(BuildContext context) { return GestureDetector( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) PhotoDetailPage(photo: photo, index: index), ), ); }, child: Card( clipBehavior: Clip.antiAlias, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: Hero( tag: photo-${photo.id}, child: Image.network( photo.url, fit: BoxFit.cover, ), ), ), Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Hero( tag: title-${photo.id}, child: Material( color: Colors.transparent, child: Text( photo.title, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ), const SizedBox(height: 4), Hero( tag: photographer-${photo.id}, child: Material( color: Colors.transparent, child: Text( 摄影师${photo.photographer}, style: TextStyle( fontSize: 12, color: Colors.grey[600], ), ), ), ), ], ), ), ], ), ), ); } } // 图片详情页面 class PhotoDetailPage extends StatelessWidget { final Photo photo; final int index; const PhotoDetailPage({ super.key, required this.photo, required this.index, }); override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, body: Stack( children: [ // 图片 GestureDetector( onTap: () Navigator.pop(context), child: Center( child: Hero( tag: photo-${photo.id}, child: Image.network( photo.url, fit: BoxFit.contain, ), ), ), ), // 信息面板 Positioned( bottom: 0, left: 0, right: 0, child: Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [ Colors.black.withOpacity(0.8), Colors.transparent, ], ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Hero( tag: title-${photo.id}, child: Material( color: Colors.transparent, child: Text( photo.title, style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white, ), ), ), ), const SizedBox(height: 8), Hero( tag: photographer-${photo.id}, child: Material( color: Colors.transparent, child: Text( 摄影师${photo.photographer}, style: TextStyle( fontSize: 16, color: Colors.grey[400], ), ), ), ), ], ), ), ), // 关闭按钮 Positioned( top: 40, right: 16, child: IconButton( icon: const Icon(Icons.close, color: Colors.white, size: 30), onPressed: () Navigator.pop(context), ), ), ], ), ); } }总结Hero 动画是 Flutter 中创建流畅页面过渡效果的强大工具。通过合理使用 Hero 动画我们可以为用户带来更加连贯和愉悦的浏览体验。动画不仅仅是视觉的装饰更是用户体验的重要组成部分。让我们用 Hero 动画的魔法创造出令人惊叹的页面过渡效果展现 Flutter 技术的无限可能。