行业资讯
Flutter 自定义转场动画:PageRoute 与自定义路由的深度定制
Flutter 自定义转场动画PageRoute 与自定义路由的深度定制一、那个千篇一律的从右滑入让产品追了我一周产品指着竞品 App 的转场效果问我为什么他们的商品详情页是从底部弹上来的而我们的是从右边滑入答案很尴尬因为 Flutter 的MaterialPageRoute默认转场就是从右向左滑动要改成从下向上得写PageRouteBuilder。更进一步的诉求弹上来的时候背景要有个半透明遮罩详情卡片的顶部两角要圆角而且点击背景可以关闭详情页。这些需求如果攒在业务代码里实现每个页面都要复制一整套转场逻辑。自定义转场动画的本质将路由转场封装为可复用动画组件让业务开发只需要声明用什么动画而不是实现什么动画。二、PageRoute 的动画生命周期sequenceDiagram participant Navigator as Navigator participant Route as PageRoute participant Animation as Animationdouble participant Widget as 转场 Widget Navigator-Route: push(Route) Route-Route: createAnimationController() Route-Animation: controller.forward() Note over Route,Animation: transStatus forwardbr/animation 从 0.0 到 1.0 loop 每帧 Animation-Route: animation.value 更新 Route-Widget: buildTransitions(context, animation) Note over Widget: 根据 animation.valuebr/插值计算 transform/opacity end Navigator-Route: pop() Route-Animation: controller.reverse() Note over Route,Animation: transStatus reversebr/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 BottomSheetRouteT extends PageRouteBuilderT { 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完全显示 // 对于 popreverse1.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: TweenOffset( 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 ScaleFadeRouteT extends PageRouteBuilderT { 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.0opacity 从 0→1 scale: Tweendouble(begin: 0.8, end: 1.0).animate( CurvedAnimation( parent: animation, curve: Curves.easeOutBack, ), ), child: Center( child: child, ), ), ); }, ); } /// /// /// 3. 共享元素过渡封装Hero 动画增强 /// /// /// Flutter 内置的 Hero 动画负责一个 Widget 在两个页面间的连续过渡 /// 但默认的矩形裁剪过于生硬。这里做圆角 弹性缓动增强。 /// class SharedAxisRouteT extends PageRouteBuilderT { 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: TweenOffset( 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: Tweendouble(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的两个关键参数transitionDurationreverseTransitionDuration——正向和反向可以有不同时长transitionsBuilder(context, animation, secondaryAnimation, child)——用 animation 的 0→1 值驱动所有转场 Widget 的插值变化opaque: false——背景透明的路由BottomSheet、Modal 场景从哪里进来、怎么进来、背景是什么、怎么出去——这四个维度定义一个转场封装到可复用的 Route 类中全项目统一使用。不要让每个页面都在手写转场动画。
郑州网站建设
网页设计
企业官网