近期在flutter开发过程中,遇到一些需要自定义转场动画的情况,就想研究一下相关的知识,有兴趣的小伙伴可以跟我一起了解下这块的逻辑和用法
通过自定义转场可以实现常用的滑动转场、透明度转场、缩放转场,以及一些复杂的复合转场等
效果如下:
一.初识
首先我们来看下flutter常见的转场逻辑:
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) {
return const NewPage();
},
),
);
通过push方法,传入一个MaterialPageRoute包裹的新页面,就可以实现默认的转场了(默认的从右到左,返回的时候是逆向的)
如果不想使用这种默认的转场,我们可以通过自定义一个PageRoute,实现各种各样的转场动画
自定义PageRoute:
class SlideUpPageRoute<T> extends MaterialPageRoute<T> {
SlideUpPageRoute({
required WidgetBuilder builder,
RouteSettings settings,
}) : super(builder: builder, settings: settings);
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 1.0),
end: Offset.zero,
).animate(animation),
child: child,
);
}
}
使用自定义的PageRoute完成转场:
Navigator.of(context).push(
SlideUpPageRoute(
builder: (context) {
return const NewPage();
},
),
要想弄清楚转场动画的具体流程,我们需要了解路由转场是怎么实现的
如果只想了解用法,可以直接跳到第三部分:自定义转场动画
二、转场动画相关逻辑及流程
1. Navigator 路由导航
在flutter中,界面跳转是通过路由导航器Navigator来控制的
WidgetsApp或MaterialApp创建和配置了一个导航器,负责管理一个[Route]对象堆栈,不需要我们手动创建
可以通过Navigator.of(context)获取当前导航器对象,通过push和pop方法控制路由对象的进栈和出栈,实现页面的跳转
/// You can create your own subclass of one of the widget library route classes
/// like [PopupRoute], [ModalRoute], or [PageRoute], to control the animated
/// transition employed to show the route, the color and behavior of the route's
/// modal barrier, and other aspects of the route.
...
...
/// Push the given route onto the navigator that most tightly encloses the given context.
@optionalTypeArgs
static Future<T?> push<T extends Object?>(BuildContext context, Route<T> route) {
return Navigator.of(context).push(route);
}
......
@optionalTypeArgs
Future<T?> push<T extends Object?>(Route<T> route) {
assert(_debugCheckIsPagelessRoute(route));
_pushEntry(_RouteEntry(route, initialState: _RouteLifecycle.push));
return route.popped;
}
其中,Route是Flutter中通用的路由管理类, 是一个抽象类,定义了导航器和路由之间的抽象接口, 我们可以通过Route来自定义路由的创建、销毁、跳转等行为,并且可以根据自己的需求来实现不同的路由管理策略
页面路由对象route由两部分组成,即页面page和过渡效果transition; 页面page一般只构建一次,过渡效果transition是在每个帧的持续时间内动态构建的
Navigator.of(context).push(PageRouteBuilder(
opaque: false,
pageBuilder: (BuildContext context, _, __) {
return Center(child: Text('My PageRoute'));
},
transitionsBuilder: (___, Animation<double> animation, ____, Widget child) {
return FadeTransition(
opacity: animation,
child: RotationTransition(
turns: Tween<double>(begin: 0.5, end: 1.0).animate(animation),
child: child,
),
);
}
));
过渡效果transition就是我们这一次探究的重点,即转场动画
2. 常用路由转场类MaterialPageRoute、CupertinoPageRoute
/// See [MaterialPageRoute] for a route that replaces the entire screen with a
platform-adaptive transition.
在Navigator的介绍中建议参考MaterialPageRoute来实现转场路由。那么问题来了,MaterialPageRoute是什么,又为什么要使用它来实现常用路由转场呢?
-
MaterialPageRoute和CupertinoPageRoute都是Flutter提供的一个具体的路由实现类,二者作用相似,提供不同风格的转场动画,下面主要说明MaterialPageRoute的相关内容在源码中介绍如下:
/// A modal route that replaces the entire screen with a platform-adaptive transition. 一种模式路线,用平台自适应过渡代替整个屏幕。 -
MaterialPageRoute继承于抽象类PageRoute,提供了定义和管理导航栈的方法,并且会在屏幕之间进行过渡动画 -
在不同平台上有不同的转场动画表现,比如在iOS设备上提供了一个默认的从右向左的滑动转场,并附带二次动画和手势转场
MaterialPageRoute的初始化
MaterialPageRoute({
required this.builder,
RouteSettings? settings,
this.maintainState = true,
bool fullscreenDialog = false,
}) : assert(builder != null),
assert(maintainState != null),
assert(fullscreenDialog != null),
super(settings: settings, fullscreenDialog: fullscreenDialog) {
assert(opaque);
}
builder参数- 构建函数,返回一个Widget,即为路由跳转的具体页面,根据push和pop的时间在不同的上下文中构建和重建
settings参数- 配置信息,用于传递路由信息,每个路由都有一个唯一的RouteSettings对象,它包含路由的名称和参数
- 主要作用是帮助Flutter在路由导航过程中正确地管理路由栈,从而实现页面跳转和返回。在使用Navigator进行页面跳转时,我们可以通过它来传递路由参数,同时也可以通过它来获取当前路由的名称和参数
- 另外还可以用于实现路由拦截和重定向。通过重写Navigator的onGenerateRoute方法,我们可以在路由跳转时对路由进行拦截和修改,例如在用户没有登录的情况下,可以将路由重定向到登录页面。
maintainState参数- 默认情况下,当一个路由被另一个替换时,上一个路由将保留在内存中。若要在不需要时释放所有资源,请将其设置为false
fullscreenDialog参数- 指定传入路由是否为全屏模式对话框
3.ModalRoute 模态路由
PageRoute是MaterialPageRoute的父类,ModalRoute又是PageRoute的父类,二者都是是抽象类,定义了一些路由类需要用到的的属性和方法
ModalRoute(模态路由),是一种特殊的路由,它会覆盖在前一个路由之上,并且需要用户进行一些操作后才能返回前一个路由,该抽象类定义了两个关键的方法
buildPage方法- 用于构建路由页面的Widget
buildTransitions方法- 提供自定义的页面过渡动画效果,会在模态路由进入或退出时被调用
- 参数
context上下文对象animationAnimation<double>类型,进入或退出的动画,当Navigator将一条路线推到其堆栈顶部时,新路线的主要动画从0.0运行到1.0。当推出栈顶时,主动画从1.0运行到0.0。secondaryAnimation辅助动画,用于被推入界面,即该界面push到下一界面时该界面的转场动画;当Navigator将新路线推到堆栈顶部时,旧的最顶层路线的secondary动画从0.0运行到1.0。当Navigator退出最上方的路线时,其下方的路线的secondary从1.0运行到0.0child转场的子元素
buildTransitions方法决定了路由转场动画的形式
上面提到的MaterialPageRoute的默认转场动画,就是因为其buildPage和buildTransitions方法由MaterialRouteTransitionMixin混入类完成了实现
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
final Widget result = buildContent(context);
assert(() {
if (result == null) {
throw FlutterError(
'The builder for route "${settings.name}" returned null.\n'
'Route builders must never return null.',
);
}
return true;
}());
return Semantics(
scopesRoute: true,
explicitChildNodes: true,
child: result,
);
}
@override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
final PageTransitionsTheme theme = Theme.of(context).pageTransitionsTheme;
return theme.buildTransitions<T>(this, context, animation, secondaryAnimation, child);
}
......
......
Duration get transitionDuration => const Duration(milliseconds: 300);
如果想自定义转场路由,
-
可以创建一个继承于
PageRoute的类,并重写各项其抽象方法@override bool get opaque => true; @override bool get barrierDismissible => false; @override bool canTransitionTo(TransitionRoute<dynamic> nextRoute) => nextRoute is PageRoute; @override bool canTransitionFrom(TransitionRoute<dynamic> previousRoute) => previousRoute is PageRoute; ... ...以及
ModalRoute的抽象方法Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation); Widget buildTransitions( BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child, ) { return child; } -
也可以直接继承
MaterialPageRoute类,其中对很多抽象方法已经做了实现,这样我们只需要实现buildTransitions方法即可,建议采用这种方式,懒人福音需要注意:直接继承
MaterialPageRoute类会导致二次动画不生效,如果需要实现二次动画的话,就要使用上面的方法了如果想要更改转场动画时长,可以重写
transitionDuration属性的get方法Duration get transitionDuration => const Duration(milliseconds: 300); -
也可以通过
PageRouteBuilder来创建路由,效果一致 PageRouteBuilder( transitionDuration: Duration(milliseconds: 500), pageBuilder: (_, __, __) => NewPage(), transitionsBuilder: (, animation, __, child) => SlideTransition( position: Tween( begin: Offset(0, 1), end: Offset.zero, ).animate(animation), child: child, ), );
4.TransitionRoute 转场路由
TransitionRoute类是ModalRoute的父类,也是一个抽象类,是Flutter中路由动画的基础,它为我们提供了一个统一的接口,可以方便地实现各种路由动画效果
上面提到的transitionDuration转场时间就是在这里定义的,甚至可以修改返回动画的转场时间reverseTransitionDuration与进入动画不一致
进入动画animation和二次动画secondaryAnimation的动画对象也在这里定义
Duration get transitionDuration;
Duration get reverseTransitionDuration => transitionDuration;
Animation<double>? get animation => _animation;
Animation<double>? _animation;
Animation<double>? get secondaryAnimation => _secondaryAnimation;
final ProxyAnimation _secondaryAnimation = ProxyAnimation(kAlwaysDismissedAnimation);
此外在这个类里面还定义了动画控制器_controller,负责控制动画进度
5.OverlayRoute 悬浮窗口路由
OverlayRoute类是TransitionRoute的父类,同样也是一个抽象类,主要作用是在当前Widget树上添加一个悬浮窗口,路由的转场基于这个功能实现,
可以通过调用Navigator.of(context).push方法来显示OverlayRoute,调用Navigator.of(context).pop方法来关闭OverlayRoute。
综上:路由类的层级为 MaterialPageRoute/CupertinoPageRoute => PageRoute => ModalRoute => TransitionRoute => OverlayRoute => Route
三、自定义转场动画
嘿,醒醒,擦擦口水,终于到了大家最关心的转场动画实现的环节了
像上面说到的,自定义转场动画,需要实现buildTransitions方法,里面根据传入的Animation对象来构建动画的具体实现Widget
关于转场动画动画的部分,可以使用Animation和AnimatedWidget来完成
Animation- 一个表示动画的抽象类,它包含有关动画的状态和进度信息,Animation对象本身并不能实现动画效果
- 通常使用Tween对象来定义动画的开始和结束状态,并根据一些插值器计算动画的中间状态
AnimatedWidget- 一个用于动画效果的抽象类,当Listenable参数发生改变时,会重新构建。
- 可以使用Animation对象的值来构建其自己的UI
AnimatedWidget是一个抽象类,具体可以使用它的一些子类Widget,例如FadeTransition、SlideTransition等,也可以是自定义的动画AnimatedBuilder。
1.系统提供的转场动画类
可用于转场的动画类
SlideTransition滑动动画ScaleTransition缩放动画RotationTransition旋转动画FadeTransition透明度动画
用于小部件内动画的类
PositionedTransition位置动画RelativePositionedTransition相对位置动画SizeTransition尺寸大小动画AlignTransition位置变化动画SliverFadeTransitionSliver过渡动画DefaultTextStyleTransition默认文本样式过渡DecoratedBoxTransition装饰盒子过渡
下面来介绍一下这几种转场动画的用法
1.1 SlideTransition 滑动转场动画
const SlideTransition({
Key? key,
required Animation<Offset> position,
this.transformHitTests = true,
this.textDirection,
this.child,
}) : assert(position != null),
super(key: key, listenable: position);
监测对象为positio动画,包含位置信息
-
transformHitTests参数 用于指示命中测试是否应该转换为父级坐标系中的坐标 -
textDirection参数 文本方向
可以用Tween来实现,用于在一段时间内从一个值转换到另一个值的类,它可以用于动画效果、渐变效果等,支持不同类型的值变化,如Color、Offset等等。
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 1.0),
end: Offset.zero,
).chain(CurveTween(curve: Curves.ease)).animate(animation),
child: child,
);
}
这里我们通过animation属性创建了一个从下到上,先加速后减速的转场动画.
上面部分有提到secondaryAnimation是辅助动画, 用于被推入界面,即该界面push到下一界面时的转场动画, 我们来试一下效果
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(-1.0, 0),
end: Offset.zero,
).animate(animation),
child: SlideTransition(
position: Tween<Offset>(
begin: Offset.zero,
end: const Offset(1.0, 0),
).animate(secondaryAnimation),
child: child,
),
);
}
这里创建的还是从下到上的转场动画,但是当新push的界面再次发生转场时,该界面会出现一个从上到底部的转场。
即A界面push到B时,B界面执行从右到左的侧滑动画,当B界面push到C时,B界面从顶部滑动到底部消失,同时C界面执行自己的转场动画
ng动画
1.2 ScaleTransition 缩放转场动画
const ScaleTransition({
super.key,
required Animation<double> scale,
this.alignment = Alignment.center,
this.filterQuality,
this.child,
}) : assert(scale != null),
super(listenable: scale);
监测对象为scale动画,包含缩放比例信息
-
alignment参数为缩放对齐方式 -
filterQuality参数为图像过滤质量
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
return ScaleTransition(
scale: Tween<double>(
begin: 2.0,
end: 1.0,
).chain(CurveTween(curve: Curves.ease)).animate(animation),
alignment: Alignment.topRight,
child: child,
);
}
可以通过以上方式实现一个以右上角为基点,从两倍视图缩放到正常大小的转场动画
1.3 RotationTransition旋转转场动画
const RotationTransition({
super.key,
required Animation<double> turns,
this.alignment = Alignment.center,
this.filterQuality,
this.child,
}) : assert(turns != null),
super(listenable: turns);
监测对象为turns动画,包含旋转的角度信息,1.0表示旋转360度, 0.5表示旋转180度
-
alignment:旋转中心点的对齐方式,默认中心对齐 -
filterQuality参数为图像过滤质量
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
return RotationTransition(
turns: Tween<double>(
begin: 0.5,
end: 0,
).chain(CurveTween(curve: Curves.ease)).animate(animation),
alignment: Alignment.topLeft,
child: child,
);
}
这里实现了一个180度,从左上角旋转进入的转场动画
1.4 FadeTransition 透明度转场动画
const FadeTransition({
Key? key,
required this.opacity,
this.alwaysIncludeSemantics = false,
Widget? child,
}) : assert(opacity != null),
super(key: key, child: child);
监测对象为opacity动画,包含透明度信息,
alwaysIncludeSemantics: 用于控制是否将子组件的语义信息始终包括在FadeTransition的语义树中,参数为true时,不论子组件的透明度是多少,子组件的语义信息始终会被包括在FadeTransition的语义树中,这意味着子组件在语义上仍然是可访问的。当alwaysIncludeSemantics参数为false时,子组件在不可见时也不会被包括在语义树中,因此无法被访问。
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
return FadeTransition(
opacity: Tween<double>(
begin: 0,
end: 1,
).chain(CurveTween(curve: Curves.ease)).animate(animation),
child: child,
);
}
这里实现了一个透明度渐变显示从0到1的转场动画
2.界面内转场动画
上面提到的都是路由转场时自定义的动画,我们在这里简称为路由转场动画
除了路由转场动画外,在界面build时也可以添加自定义的转场动画,为了便于区分,我们称之为界面内转场动画
界面内转场动画与路由转场动画相互独立,但与路由转场动画一致的是,界面内转场动画也仅会在界面路由push和pop时生效
界面内转场动画可以使用ModalRoute.of(context)!.animation获取对应的转场动画进度相关信息来实现
下面实现了一个界面出现时,除了路由转场动画外,还有一个逐步放大的图片的界面转场动画
@override
Widget build(BuildContext context) {
return Center(
child: SizeTransition(
sizeFactor: Tween<double>(begin: 0, end: 1.0).animate(
CurvedAnimation(
parent: ModalRoute.of(context)!.animation!,
curve: Curves.easeInOut,
),
),
child: Scaffold(
appBar: AppBar(
title: const Text('子部件尺寸动画转场界面'),
),
body: const Icon(
Icons.flutter_dash,
size: 400,
),
),
),
);
}
路由转场这里我们使用自定义的无动画效果的转场,也可以结合上述的各种动画效果来实现
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
return child;
}
界面内自定义转场动画灵活度较高,可以使用系统提供的各种动画Widget,也可以进行各种自定义
3.AnimatedBuilder实现自定义动画
对于涉及额外状态的更复杂情况,请考虑使用AnimatedBuilder。
const AnimatedBuilder({
super.key,
required Listenable animation,
required this.builder,
this.child,
}) : assert(animation != null),
assert(builder != null),
super(listenable: animation);
animation: 监听动画,值发生变化时调用builder函数builder: 创建函数,将接收一个BuildContext和一个Widget参数,该Widget参数将在每次调用时重建并更新其状态
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
return AnimatedBuilder(
animation: animation,
builder: (BuildContext context, Widget? child) {
return Transform.rotate(
angle: animation.value * pi * 2,
child: Transform.scale(
scale: animation.value,
child: child
),
);
},
child: child,
);
}
上面我们实现了一个旋转+缩放的动画转场效果
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
return AnimatedBuilder(
animation: animation,
builder: (BuildContext context, Widget? child) {
return Transform.translate(
offset: Offset((1 - animation.value) * 375, (1 - animation.value) * 500),
child: Transform.scale(
scale: animation.value,
child: Opacity(
opacity: animation.value,
child: child,
),
),
);
},
child: child,
);
}
上面实现了一个位置变换+渐显动画转场效果
四、拓展
转场过程添加手势
在iOS机型的日常使用过程中,大家会发现flutter提供的默认转场,支持通过手势进行侧滑返回,以及取消转场等,跟原生的转场效果类似
这些是通过CupertinoPageRoute混入CupertinoRouteTransitionMixin类来实现的
static bool isPopGestureInProgress(PageRoute<dynamic> route) {
return route.navigator!.userGestureInProgress;
}
bool get popGestureInProgress => isPopGestureInProgress(this);
bool get popGestureEnabled => _isPopGestureEnabled(this);
static bool _isPopGestureEnabled<T>(PageRoute<T> route) {
...
...
}
static _CupertinoBackGestureController<T> _startPopGesture<T>(PageRoute<T> route) {
assert(_isPopGestureEnabled(route));
return _CupertinoBackGestureController<T>(
navigator: route.navigator!,
controller: route.controller!, // protected access
);
}
popGestureInProgress属性用于指示是否正在进行手势返回操作
_isPopGestureEnabled方法用于控制Cupertino页面路由转换中手势返回操作的启用状态
_startPopGesture方法是开始手势返回操作
其中通过手势进行返回转场的核心逻辑是在buildPageTransitions方法中通过_CupertinoBackGestureDetector实现的
static Widget buildPageTransitions<T>(
PageRoute<T> route,
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
// Check if the route has an animation that's currently participating
// in a back swipe gesture.
//
// In the middle of a back gesture drag, let the transition be linear to
// match finger motions.
final bool linearTransition = isPopGestureInProgress(route);
if (route.fullscreenDialog) {
return CupertinoFullscreenDialogTransition(
primaryRouteAnimation: animation,
secondaryRouteAnimation: secondaryAnimation,
linearTransition: linearTransition,
child: child,
);
} else {
return CupertinoPageTransition(
primaryRouteAnimation: animation,
secondaryRouteAnimation: secondaryAnimation,
linearTransition: linearTransition,
child: _CupertinoBackGestureDetector<T>(
enabledCallback: () => _isPopGestureEnabled<T>(route),
onStartPopGesture: () => _startPopGesture<T>(route),
child: child,
),
);
}
}
其核心内容是根据手势拖拽的距离换算成比例,来设置TransitionRoute类提供的controller动画对象的值,从而实现手势对转场进度的控制
详细代码因为具体篇幅较长就不在文章内粘出了,有兴趣的可以参考下demo
实现了一个手势控制透明度转场,效果如下:
参考: