页面转场时如何将Widget动画至新位置?——仿Google应用搜索栏转场动画实现咨询
实现搜索栏跨页面平滑过渡的方案(仿Google应用效果)
刚好做过类似的需求,这种跨页面的共享元素位置/尺寸过渡,用Flutter的Hero动画+自定义过渡逻辑就能完美实现,比单独用AnimatedPositioned或者普通路由动画更贴合场景。下面是具体的实现思路和代码示例:
核心思路
我们要让同一个搜索栏组件在两个页面间"无缝移动":
- 用
Hero组件标记两个页面的搜索栏,让Flutter识别这是共享元素 - 自定义
flightShuttleBuilder,在过渡过程中动态调整搜索栏的位置、尺寸 - 配合页面背景的淡入淡出动画,让整体效果更自然
步骤1:封装可动画的搜索栏组件
先写一个能根据动画值调整自身布局的搜索栏,它会根据传入的动画参数,从首页的中央状态平滑过渡到搜索页的顶部状态:
class AnimatedSearchBar extends StatelessWidget { final Animation<double> animation; final VoidCallback onTap; final bool isHomePage; const AnimatedSearchBar({ super.key, required this.animation, required this.onTap, required this.isHomePage, }); @override Widget build(BuildContext context) { final screenSize = MediaQuery.of(context).size; final statusBarHeight = MediaQuery.of(context).padding.top; // 定义首页和搜索页的搜索栏参数 final homeState = { 'top': screenSize.height / 2 - 25, // 垂直居中(搜索栏高度50) 'width': screenSize.width * 0.8, // 宽度占屏幕80% 'borderRadius': 25.0, }; final searchPageState = { 'top': statusBarHeight + 16, // 状态栏下方+16px间距 'width': screenSize.width - 32, // 左右各留16px边距 'borderRadius': 12.0, }; // 通过动画插值计算当前状态 final currentTop = Tween<double>( begin: homeState['top'], end: searchPageState['top'], ).animate(animation).value; final currentWidth = Tween<double>( begin: homeState['width'], end: searchPageState['width'], ).animate(animation).value; final currentRadius = Tween<double>( begin: homeState['borderRadius'], end: searchPageState['borderRadius'], ).animate(animation).value; return Positioned( top: currentTop, left: (screenSize.width - currentWidth) / 2, child: SizedBox( width: currentWidth, height: 50, child: TextField( decoration: InputDecoration( hintText: '搜索', filled: true, fillColor: Colors.grey[200], border: OutlineInputBorder( borderRadius: BorderRadius.circular(currentRadius), borderSide: BorderSide.none, ), prefixIcon: const Icon(Icons.search), ), onTap: onTap, enabled: !isHomePage || animation.status == AnimationStatus.completed, ), ), ); } }
步骤2:首页(含中央搜索栏)
用Hero包裹搜索栏,指定唯一的tag,同时在flightShuttleBuilder中返回过渡中的动态搜索栏:
class HomePage extends StatelessWidget { const HomePage({super.key}); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, body: Stack( children: [ Hero( tag: 'search_bar', flightShuttleBuilder: (flightContext, animation, flightDirection, fromHero, toHero) { // 过渡过程中使用动画驱动搜索栏变化 return AnimatedSearchBar( animation: animation, onTap: () {}, isHomePage: flightDirection == HeroFlightDirection.push, ); }, child: AnimatedSearchBar( animation: const AlwaysStoppedAnimation(0), // 初始状态(首页) onTap: () { // 跳转到搜索页,用自定义路由实现背景过渡 Navigator.push( context, PageRouteBuilder( pageBuilder: (context, anim, secondaryAnim) => const SearchPage(), transitionsBuilder: (context, anim, secondaryAnim, child) { // 首页淡出 + 搜索页淡入 final fadeOut = Tween<double>(begin: 1, end: 0).animate( CurvedAnimation(parent: anim, curve: const Interval(0, 0.5)), ); final fadeIn = Tween<double>(begin: 0, end: 1).animate( CurvedAnimation(parent: anim, curve: const Interval(0.5, 1)), ); return Stack( children: [ FadeTransition(opacity: fadeOut, child: const HomePage()), FadeTransition(opacity: fadeIn, child: child), ], ); }, ), ); }, isHomePage: true, ), ), // 首页其他内容(比如logo、快捷入口) const Center( child: Text('首页内容'), ), ], ), ); } }
步骤3:搜索页(含顶部搜索栏)
同样用Hero包裹搜索栏,初始状态为搜索页的顶部样式:
class SearchPage extends StatelessWidget { const SearchPage({super.key}); @override Widget build(BuildContext context) { final pageAnim = ModalRoute.of(context)!.animation!; return Scaffold( backgroundColor: Colors.white, body: Stack( children: [ Hero( tag: 'search_bar', child: AnimatedSearchBar( animation: const AlwaysStoppedAnimation(1), // 目标状态(搜索页) onTap: () {}, isHomePage: false, ), ), // 搜索结果区域:等过渡完成后淡入 FadeTransition( opacity: Tween<double>(begin: 0, end: 1).animate( CurvedAnimation(parent: pageAnim, curve: const Interval(0.6, 1)), ), child: Positioned( top: MediaQuery.of(context).padding.top + 16 + 50 + 16, left: 16, right: 16, child: ListView.builder( itemCount: 20, itemBuilder: (context, index) => ListTile(title: Text('搜索结果 $index')), ), ), ), ], ), ); } }
关键细节说明
- Hero的核心作用:让Flutter识别两个页面的搜索栏是同一个元素,自动处理过渡时的"飞行"逻辑,我们只需要自定义飞行过程中的样式
- 动画曲线优化:可以把
CurvedAnimation的曲线换成Curves.fastOutSlowIn,这是Material Design推荐的过渡曲线,和Google应用的动画节奏更匹配 - 交互控制:过渡过程中禁用搜索栏的点击(
enabled属性),避免用户在动画未完成时误操作 - 适配不同设备:用
MediaQuery获取状态栏高度和屏幕尺寸,确保在各种设备上的位置都准确
这种方案完全符合你要的效果:点击中央搜索栏,它会平滑移动到顶部,同时页面背景淡入淡出,和Google应用的搜索转场一模一样。
内容的提问来源于stack exchange,提问作者camelCaseIsGoodPractice
相关产品推荐
相关产品推荐

