Flutter 自定义转场动画:PageRoute 与自定义路由的深度定制
一、那个"千篇一律的从右滑入"让产品追了我一周
产品指着竞品 App 的转场效果问我:"为什么他们的商品详情页是从底部弹上来的,而我们的是从右边滑入?"答案很尴尬:因为 Flutter 的MaterialPageRoute默认转场就是"从右向左滑动",要改成"从下向上",得写PageRouteBuilder。
更进一步的诉求:"弹上来的时候,背景要有个半透明遮罩,详情卡片的顶部两角要圆角,而且点击背景可以关闭详情页。"这些需求如果攒在业务代码里实现,每个页面都要复制一整套转场逻辑。
自定义转场动画的本质:将路由转场封装为可复用动画组件,让业务开发只需要"声明用什么动画"而不是"实现什么动画"。
二、PageRoute 的动画生命周期
sequenceDiagram participant Navigator as Navigator participant Route as PageRoute participant Animation as Animation<double> participant Widget as 转场 Widget Navigator->>Route: push(Route) Route->>Route: createAnimationController() Route->>Animation: controller.forward() Note over Route,Animation: transStatus = forward<br/>animation 从 0.0 到 1.0 loop 每帧 Animation->>Route: animation.value 更新 Route->>Widget: buildTransitions(context, animation) Note over Widget: 根据 animation.value<br/>插值计算 transform/opacity end Navigator->>Route: pop() Route->>Animation: controller.reverse() Note over Route,Animation: transStatus = reverse<br/>animation 从 1.0 到 0.0 Animation-->>Navigator: animation.status == dismissed Navigator->>Route: dispose()三、自定义转场组件实现
// lib/routes/custom_page_route.dart // 自定义路由转场动画集合 import 'package:flutter/material.dart'; /// /// ======================================== /// 1. 从底部弹出(Bottom Sheet 风格) /// ======================================== /// /// 适用于:商品详情、操作菜单、筛选面板 /// /// 动画特征: /// - 卡片从底部滑入 /// - 背景叠加半透明遮罩 /// - 点击遮罩可关闭 /// - 顶部圆角仿物理卡片 class BottomSheetRoute<T> extends PageRouteBuilder<T> { final Widget child; /// [barrierDismissible] 是否点击遮罩关闭 /// [barrierColor] 遮罩颜色 BottomSheetRoute({ required this.child, bool barrierDismissible = true, Color barrierColor = Colors.black54, }) : super( // 遮罩层配置 barrierDismissible: barrierDismissible, barrierColor: barrierColor, opaque: false, // 关键:背景透明才能看到遮罩下方的页面 // // 过渡时长控制: // 弹出(forward)比关闭(reverse)略长,符合"进入要稳、退出要快"的直觉 transitionDuration: const Duration(milliseconds: 350), reverseTransitionDuration: const Duration(milliseconds: 250), pageBuilder: (context, animation, secondaryAnimation) => child, transitionsBuilder: (context, animation, secondaryAnimation, child) { // // 底部弹出的核心动画计算 // // animation.value: 0.0(完全隐藏)→ 1.0(完全显示) // 对于 pop(reverse):1.0 → 0.0 // return Stack( children: [ // 遮罩层动画:淡入淡出 FadeTransition( opacity: animation, child: GestureDetector( onTap: barrierDismissible ? () => Navigator.of(context).pop() : null, child: Container(color: Colors.transparent), ), ), // 卡片动画:从底部滑入 + 弹性缓动 Align( alignment: Alignment.bottomCenter, child: SlideTransition( position: Tween<Offset>( begin: const Offset(0, 1), // 屏幕下方 end: Offset.zero, // 屏幕底边对齐 ).animate(CurvedAnimation( parent: animation, // easeOutBack:先快后慢 + 轻微超出目标位置再回弹 // 模拟物理上的"弹入"感 curve: Curves.easeOutBack, reverseCurve: Curves.easeIn, // 退出时无回弹,直接下滑 )), child: SafeArea( child: ClipRRect( // 顶部圆角:只在卡片上边加圆角,下边不留 borderRadius: const BorderRadius.vertical( top: Radius.circular(16), ), child: child, ), ), ), ), ], ); }, ); } /// /// ======================================== /// 2. 从中心缩放弹出(iOS 风格 Alert) /// ======================================== /// /// 适用于:确认弹窗、登录模态框 /// /// 动画特征: /// - 从 0.8 缩放至 1.0 + 透明度 0→1 /// - 背景模糊而非纯黑遮罩 class ScaleFadeRoute<T> extends PageRouteBuilder<T> { final Widget child; ScaleFadeRoute({ required this.child, bool barrierDismissible = true, }) : super( barrierDismissible: barrierDismissible, barrierColor: Colors.black54, opaque: false, transitionDuration: const Duration(milliseconds: 300), pageBuilder: (context, animation, secondaryAnimation) => child, transitionsBuilder: (context, animation, secondaryAnimation, child) { return FadeTransition( opacity: animation, child: ScaleTransition( // 组合动画:scale 从 0.8→1.0,opacity 从 0→1 scale: Tween<double>(begin: 0.8, end: 1.0).animate( CurvedAnimation( parent: animation, curve: Curves.easeOutBack, ), ), child: Center( child: child, ), ), ); }, ); } /// /// ======================================== /// 3. 共享元素过渡封装(Hero 动画增强) /// ======================================== /// /// Flutter 内置的 Hero 动画负责"一个 Widget 在两个页面间的连续过渡", /// 但默认的矩形裁剪过于生硬。这里做圆角 + 弹性缓动增强。 /// class SharedAxisRoute<T> extends PageRouteBuilder<T> { final Widget child; final Axis direction; // 滑动方向 SharedAxisRoute({ required this.child, this.direction = Axis.horizontal, }) : super( transitionDuration: const Duration(milliseconds: 350), reverseTransitionDuration: const Duration(milliseconds: 250), pageBuilder: (context, animation, secondaryAnimation) => child, transitionsBuilder: (context, animation, secondaryAnimation, child) { final isHorizontal = direction == Axis.horizontal; return SlideTransition( position: Tween<Offset>( begin: isHorizontal ? const Offset(0.3, 0) // 从右 30% 处滑入(留一点让旧页面可见) : const Offset(0, 0.3), // 从下方 30% 处滑入 end: Offset.zero, ).animate(CurvedAnimation( parent: animation, curve: Curves.easeOut, reverseCurve: Curves.easeIn, )), child: FadeTransition( opacity: Tween<double>(begin: 0.0, end: 1.0).animate(animation), child: child, ), ); }, ); } /// /// ======================================== /// 使用示例 /// ======================================== /// class ExampleUsage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('转场示例')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // 1. 底部弹出详情页 ElevatedButton( onPressed: () { Navigator.of(context).push( BottomSheetRoute( child: ProductDetailPage(), ), ); }, child: const Text('从底部弹出商品详情'), ), // 2. 缩放弹出确认框 ElevatedButton( onPressed: () { Navigator.of(context).push( ScaleFadeRoute( child: Material( color: Colors.transparent, child: ConfirmDialog(), ), ), ); }, child: const Text('缩放弹出确认框'), ), ], ), ), ); } }四、自定义转场的性能与边界
opaque: false的性能代价。当路由不透明(opaque: true,默认值)时,Flutter 知道下方的页面完全被遮挡,不会继续渲染它——这是一种 GPU 层级优化。设为false后,下方的页面持续渲染,在复杂页面(如包含 WebView、地图、大量动画)下会有明显性能影响。
Hero和自定义路由的兼容性。当自定义PageRoute中既有SlideTransition又有页面内的Hero动画时,两个动画系统的时间线可能不同步——Hero有自己独立的AnimationController,而自定义路由使用PageRoute的animation。解决方案是使用secondaryAnimation参数:它代表"当前路由被压入新页面下方时"的动画,传递给Hero的flightShuttleBuilder来同步时间线。
过度定制的问题。PageRouteBuilder的灵活性容易让人在每个页面都写一套转场逻辑。这恰恰是要避免的:用 3-5 个标准转场封装(底部弹出、缩放弹出、共享轴滑动),业务代码只选择不实现。
五、总结
Flutter 自定义转场动画的核心是PageRouteBuilder的两个关键参数:
transitionDuration+reverseTransitionDuration——正向和反向可以有不同时长transitionsBuilder(context, animation, secondaryAnimation, child)——用 animation 的 0→1 值驱动所有转场 Widget 的插值变化opaque: false——背景透明的路由(BottomSheet、Modal 场景)
"从哪里进来、怎么进来、背景是什么、怎么出去"——这四个维度定义一个转场,封装到可复用的 Route 类中,全项目统一使用。不要让每个页面都在"手写转场动画"。