写这个的一切起因都得从我某天切换了酷安App的夜间模式说起,看个Gif,忽略图中其他无关项。
这种的动画在awesome-Flutter上好像见到过,但是记得只是类似,有一个App的首次引导页跟这个有点像,不过那个是在一个PageView切换的时候的动画。上图的酷安App是原生应用,可以看到我在第三次切换主题的时候滑动了一个横向的类似于Flutter ListView的东西,再次点击切换主题,ListView的状态变化了,所以我怀疑酷安是用StartActivity的方式(太久没碰原生UI了,所以只是猜测)
起初用showOverlay的方式来做一个,效果始终不好,有违和感。后来果断采用自定义路由,最后整个动画的难点全在自定义路由上,如果你对原理不感兴趣,滑动到最后有现成的代码
新的页面是由点击的控件中心所在的坐标位置呈一个圆形逐渐扩散开来,最后撑满整个屏幕,不会啥绘图工具,用我Mac自带的绘图顶一下
最中心的点是按钮的中心坐标,图中第二个页面的父布局即为整个圆形,这是整个动画中间的某一时刻
最后会是这个样子
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gHvWOLzJ-1580971191444)(https://upload-images.jianshu.io/upload_images/14486200-3d63c042febb0d1b.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
通过如下代码计算出这个按钮所在的坐标,当然也可以使用GlobalObjectKey(value).currentContext来拿到控件的上下文
final RenderBox renderBox = iconContext.findRenderObject();
offset = renderBox.localToGlobal(renderBox.size.center(Offset.zero));
也就是说,圆的最大会刚好覆盖住手机屏幕最远的那个角落,如果小于了,则第二个页面显示不完整,大于了则会浪费多余的动画时长,看一下路由圆的直径计算代码,用了比较笨的判断,用简单的勾股定理计算出控件中心的坐标到屏幕最远的距离,路由页面的大小即为该距离的二倍。
final RenderBox renderBox = context.findRenderObject();
offset = renderBox.localToGlobal(renderBox.size.center(Offset.zero));
if (offset.dx > MediaQuery.of(context).size.width / 2) {
if (offset.dy > MediaQuery.of(context).size.height / 2) {
circleRadius = sqrt(pow(offset.dx, 2) + pow(offset.dy, 2)).toDouble();
} else {
circleRadius = sqrt(pow(offset.dx, 2) +
pow(MediaQuery.of(context).size.height - offset.dy, 2))
.toDouble();
}
}
if (offset.dx <= MediaQuery.of(context).size.width / 2) {
if (offset.dy > MediaQuery.of(context).size.height / 2) {
circleRadius = sqrt(
pow(MediaQuery.of(context).size.width - offset.dx, 2) +
pow(offset.dy, 2))
.toDouble();
} else {
circleRadius = sqrt(
pow(MediaQuery.of(context).size.width - offset.dx, 2) +
pow(MediaQuery.of(context).size.height - offset.dy, 2))
.toDouble();
}
}
}
首先我们实现这个圆的动画,我就不单独写demo了,直接拿我的工具箱做实验,定位到PageRouteBuilder的关键代码
transitionsBuilder: (
BuildContext context,
Animation animation,
Animation _,
Widget child,
) {
return Stack(
alignment: Alignment.center,
children: [
SizedBox(
height: routeConfig.circleRadius * 2 * animation.value,
width: routeConfig.circleRadius * 2 * animation.value,
child: ClipOval(
child: Align(
alignment: Alignment.center,
child: Container(
color: Colors.red,
),
),
),
),
],
);
}
这部分的代码比较好理解,routeConfig.circleRadius即为整个圆最大时的半径,路由的新页面收SziedBox的限制,而SizedBox包裹ClipOval控件来实现圆形,这部分的效果如下
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JFwQ2NDH-1580971191445)(https://upload-images.jianshu.io/upload_images/14486200-af55a75f2c46fec9.gif?imageMogr2/auto-orient/strip)]
其中的两个问题,
1.SizedBox长宽都给定的计算出的能包裹手机屏幕的值,最后并没有形成那样大的一个圆
2.圆并不是从按钮中心扩散开来的
采用Positioned解决这两个问题,起初我考虑的用Transform控件,传入参数Matrix4.identity()…translate(x,y)的方式让整个圆从控件中心展开,但并没有解决第一个问题,于是换了Positioned,Positioned可以设置与屏幕的上下左右边距,可以接收负数
观察两个临界状态,动画刚开始与动画结束,
刚开始时:animation.value=0,也就是说第二个页面的大小此时为0,此刻如果想要它在控件中心位置,它应该在哪呢?
我们上面计算出来了控件中心所在的offset,offset.dx即为控件中心与左屏幕的边距,offset.dy为与上屏幕的边距,
在观察动画结束时:第二个页面的大小为圆的直径,如果不对控件做偏移处理,它会是这个样子
我们需要做的就是将此刻的圆心移动到按钮原始中心的位置,有一个这样的图就比较好计算了,此刻圆心需要向上的偏移量即为:圆的半径-起始按钮中心距上屏幕的位置,向左的偏移量即为:圆的半径-起始按钮中心距左屏幕的位置
而整个过程是一个动画,在动画的每一刻的计算方式都如此,所以次时的代码改为
return Stack(
alignment: Alignment.center,
children: [
Positioned(
top: routeConfig.offset.dy -
routeConfig.circleRadius * animation.value,
left: routeConfig.offset.dx -
routeConfig.circleRadius * animation.value,
child: SizedBox(
height: routeConfig.circleRadius * 2 * animation.value,
width: routeConfig.circleRadius * 2 * animation.value,
child: ClipOval(
child: Align(
alignment: Alignment.center,
child: Container(
color: Colors.red,
),
),
),
),
),
],
);
传入的是负数是因为初始距离上、左屏幕的距离为0,如果是正数,圆只会越来越远离屏幕上与左,看一下此时的效果
主要的动画已经出来了,我当时的第一想法就是将这个大红色替换成我需要路由的Widget就大功告成了,然后成了下面这个样子
这也是当时困扰了我半天的问题,这个问题是由于解决上面两个问题时,及时偏移了整个圆的坐标,才导致圆包含的子页面(我们想要路由到的页面)坐标也被更改了,而我想要的是路由的页面始终显示到屏幕的位置,既然如此,再使用一个Stack+Positioned的组合,负负得正,上一个Positioned怎么传的值,我就传相反数进去,更改如下:
return Stack(
alignment: Alignment.center,
children: [
Positioned(
top: routeConfig.offset.dy -
routeConfig.circleRadius * animation.value,
left: routeConfig.offset.dx -
routeConfig.circleRadius * animation.value,
child: SizedBox(
height: routeConfig.circleRadius * 2 * animation.value,
width: routeConfig.circleRadius * 2 * animation.value,
child: ClipOval(
child: Stack(
children: [
Positioned(
top: routeConfig.circleRadius * animation.value -
routeConfig.offset.dy,
left: routeConfig.circleRadius * animation.value -
routeConfig.offset.dx,
child: Align(
alignment: Alignment.center,
child: Container(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
child: child,
),
),
)
],
),
),
),
),
],
);
效果如下:
**最后再自己把路由时间改一下就行了
import 'dart:math';
import 'package:flutter/material.dart';
class RouteConfig {
Offset offset;
double circleRadius;
RouteConfig.fromContext(BuildContext context) {
final RenderBox renderBox = context.findRenderObject();
offset = renderBox.localToGlobal(renderBox.size.center(Offset.zero));
if (offset.dx > MediaQuery.of(context).size.width / 2) {
if (offset.dy > MediaQuery.of(context).size.height / 2) {
circleRadius = sqrt(pow(offset.dx, 2) + pow(offset.dy, 2)).toDouble();
} else {
circleRadius = sqrt(pow(offset.dx, 2) +
pow(MediaQuery.of(context).size.height - offset.dy, 2))
.toDouble();
}
}
if (offset.dx <= MediaQuery.of(context).size.width / 2) {
if (offset.dy > MediaQuery.of(context).size.height / 2) {
circleRadius = sqrt(
pow(MediaQuery.of(context).size.width - offset.dx, 2) +
pow(offset.dy, 2))
.toDouble();
} else {
circleRadius = sqrt(
pow(MediaQuery.of(context).size.width - offset.dx, 2) +
pow(MediaQuery.of(context).size.height - offset.dy, 2))
.toDouble();
}
}
}
}
// double circleRadius
class RippleRoute extends PageRouteBuilder {
final Widget widget;
final RouteConfig routeConfig;
RippleRoute(this.widget, this.routeConfig)
: super(
// 设置过度时间
transitionDuration: Duration(seconds: 1),
// 构造器
pageBuilder: (
// 上下文和动画
BuildContext context,
Animation animation,
Animation _,
) {
return widget;
},
opaque: false,
transitionsBuilder: (
BuildContext context,
Animation animation,
Animation _,
Widget child,
) {
return Stack(
alignment: Alignment.center,
children: [
Positioned(
top: routeConfig.offset.dy -
routeConfig.circleRadius * animation.value,
left: routeConfig.offset.dx -
routeConfig.circleRadius * animation.value,
child: SizedBox(
height: routeConfig.circleRadius * 2 * animation.value,
width: routeConfig.circleRadius * 2 * animation.value,
child: ClipOval(
child: Stack(
children: [
Positioned(
top: routeConfig.circleRadius * animation.value -
routeConfig.offset.dy,
left: routeConfig.circleRadius * animation.value -
routeConfig.offset.dx,
child: Align(
alignment: Alignment.center,
child: Container(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
child: child,
),
),
)
],
),
),
),
),
],
);
},
);
}
###使用方法:
在Flutter里面,可将点击的按钮套上一个Builder,如以下的样式,在跳转逻辑中如下,各种计算都被我封装到了RouteConfig这个类里面了,通过context构造并传入PageRouteBuilder就行了
Builder(
builder: (iconContext) {
return InkWell(
child: Icon(
Icons.***,
),
onTap: () {
Navigator.of(context).push(
RippleRoute(
NewPage(),
RouteConfig.fromContext(context)),
);
},
);
},
);
不仅是路由相同的页面来切换主题,也可以用于任何的路由场景
那如何用这个路由切换主题?将你不包含Theme的页面独立出来,再切换主题路由新页面是套上一个新的Theme就好啦
总结了下,是写骚了一点,不过我的确想不到比较官方的写法了哈哈,好几次转gif我就懒得贴表情包了哈哈