Flutter炫酷的波纹路由动画

  写这个的一切起因都得从我某天切换了酷安 App 的夜间模式说起,看个 Gif,忽略图中其他无关项。
Gif
这种的动画在 awesome-Flutter 上好像见到过,但是记得只是类似,有一个 App 的首次引导页跟这个有点像,不过那个是在一个 PageView 切换的时候的动画。上图的酷安 App 是原生应用,可以看到我在第三次切换主题的时候滑动了一个横向的类似于 Flutter ListView 的东西,再次点击切换主题,ListView 的状态变化了,所以我怀疑酷安是用 StartActivity 的方式(太久没碰原生 UI 了,所以只是猜测)

Flutter 端的实现

起初用 showOverlay 的方式来做一个,效果始终不好,有违和感。后来果断采用自定义路由,最后整个动画的难点全在自定义路由上,如果你对原理不感兴趣,滑动到最后有现成的代码

动画分析

新的页面是由点击的控件中心所在的坐标位置呈一个圆形逐渐扩散开来,最后撑满整个屏幕,不会啥绘图工具,用我 Mac 自带的绘图顶一下
Gif
最中心的点是按钮的中心坐标,图中第二个页面的父布局即为整个圆形,这是整个动画中间的某一时刻
最后会是这个样子

Gif
通过如下代码计算出这个按钮所在的坐标,当然也可以使用 GlobalObjectKey(value).currentContext 来拿到控件的上下文

1
2
final RenderBox renderBox = iconContext.findRenderObject();
offset = renderBox.localToGlobal(renderBox.size.center(Offset.zero));

也就是说,圆的最大会刚好覆盖住手机屏幕最远的那个角落,如果小于了,则第二个页面显示不完整,大于了则会浪费多余的动画时长,看一下路由圆的直径计算代码,用了比较笨的判断,用简单的勾股定理计算出控件中心的坐标到屏幕最远的距离,路由页面的大小即为该距离的二倍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
  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 的关键代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
transitionsBuilder: (
BuildContext context,
Animation<double> animation,
Animation<double> _,
Widget child,
) {
return Stack(
alignment: Alignment.center,
children: <Widget>[
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 控件来实现圆形,这部分的效果如下
Gif
其中的两个问题,
1.SizedBox 长宽都给定的计算出的能包裹手机屏幕的值,最后并没有形成那样大的一个圆 2.圆并不是从按钮中心扩散开来的
采用 Positioned 解决这两个问题,起初我考虑的用 Transform 控件,传入参数 Matrix4.identity()..translate(x,y)的方式让整个圆从控件中心展开,但并没有解决第一个问题,于是换了 Positioned,Positioned 可以设置与屏幕的上下左右边距,可以接收负数
观察两个临界状态,动画刚开始与动画结束,
刚开始时:animation.value=0,也就是说第二个页面的大小此时为 0,此刻如果想要它在控件中心位置,它应该在哪呢?
我们上面计算出来了控件中心所在的 offset,offset.dx 即为控件中心与左屏幕的边距,offset.dy 为与上屏幕的边距,
在观察动画结束时:第二个页面的大小为圆的直径,如果不对控件做偏移处理,它会是这个样子

Gif
我们需要做的就是将此刻的圆心移动到按钮原始中心的位置,有一个这样的图就比较好计算了,此刻圆心需要向上的偏移量即为:圆的半径-起始按钮中心距上屏幕的位置,向左的偏移量即为:圆的半径-起始按钮中心距左屏幕的位置
而整个过程是一个动画,在动画的每一刻的计算方式都如此,所以次时的代码改为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
return Stack(
alignment: Alignment.center,
children: <Widget>[
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,如果是正数,圆只会越来越远离屏幕上与左,看一下此时的效果

Gif
主要的动画已经出来了,我当时的第一想法就是将这个大红色替换成我需要路由的 Widget 就大功告成了,然后成了下面这个样子
Gif
这也是当时困扰了我半天的问题,这个问题是由于解决上面两个问题时,及时偏移了整个圆的坐标,才导致圆包含的子页面(我们想要路由到的页面)坐标也被更改了,而我想要的是路由的页面始终显示到屏幕的位置,既然如此,再使用一个 Stack+Positioned 的组合,负负得正,上一个 Positioned 怎么传的值,我就传相反数进去,更改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
return Stack(
alignment: Alignment.center,
children: <Widget>[
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: <Widget>[
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,
),
),
)
],
),
),
),
),
],
);

**效果如下:**\

Gif
**最后再自己把路由时间改一下就行了

附上 ripple_router.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
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<double> animation,
Animation<double> _,
) {
return widget;
},
opaque: false,
transitionsBuilder: (
BuildContext context,
Animation<double> animation,
Animation<double> _,
Widget child,
) {
return Stack(
alignment: Alignment.center,
children: <Widget>[
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: <Widget>[
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 就行了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Builder(
builder: (iconContext) {
return InkWell(
child: Icon(
Icons.***,
),
onTap: () {
Navigator.of(context).push(
RippleRoute(
NewPage(),
RouteConfig.fromContext(context)),
);
},
);
},
);

不仅是路由相同的页面来切换主题,也可以用于任何的路由场景

Gif

那如何用这个路由切换主题?将你不包含 Theme 的页面独立出来,再切换主题路由新页面是套上一个新的 Theme 就好啦
总结了下,是写骚了一点,不过我的确想不到比较官方的写法了哈哈,好几次转 gif 我就懒得贴表情包了哈哈

作者

梦魇兽

发布于

2020-02-06

更新于

2023-03-11

许可协议

评论