Flutter练习第一弹-酷炫入场动画
完整效果如上图
作为一个老年Android,我已经一年多没写Android代码了,所以最近在复健、但是Android实在没搞头,所以决定学下Flutter。学Flutter大概一个多星期、这个算是咱第一个比较完整的功能代码了,在这里给大家分享下我的思路和关键代码
设计稿来源
秉承着能搬就搬的原则,咱从dribbble上搜索weather关键字、找到了这个酷炫的效果图,于是看看能不能复刻出来 # Mobile | Weather app
布局实现
代码不复杂,这里我就不详细讲了。作为Flutter新手的我、能快速搞定布局、下面两个工具真是出了大力。
这里我随便举个copilot牛皮的例子
上面这个布局,我才写了个类名
class ForecastLayout extends StatefulWidget {
接下来神奇的copilot就给我生成了下面一大堆代码
数据类都给我写好了,他为什么知道我要用这三个字段、是因为forecast是预测的意思么!!! 这里ai知道我在写天气相关的ui、给我生成了day和temperature两个字段就算了,竟然还知道给我加个icon,你说神奇不神奇!!!
除了边框、间距、背景色基本都和最终结果八九不离十
上升动画
入场动画中大部分都是这种上升+渐现的组合动画,Flutter提供了很多简化动画写法的封装类,由于咱刚学也在摸索,所以写了好几种实现
方法一:SlideTransition+FadeTransition(推荐)
SlideTransition
控制y方向上移动的距离,这里的0.5就表示child
的高度的一半FadeTransition
控制child的透明度从0到1
class UpAnimationLayout extends StatefulWidget {
final AnimateCallback? callback;
final AnimationStatusListener? statusListener;
final Widget child;
final double? upOffset;
final Duration? duration;
const UpAnimationLayout(
{super.key,
required this.child,
this.callback,
this.statusListener,
this.upOffset,
this.duration});
@override
State<StatefulWidget> createState() => _UpAnimationLayoutState();
}
class _UpAnimationLayoutState extends State<UpAnimationLayout> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this, duration: widget.duration ?? const Duration(milliseconds: 500))
..addListener(() {
widget.callback?.call(_controller);
})
..addStatusListener(widget.statusListener ?? (status) {});
}
void start() {
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SlideTransition(
position:
Tween(begin: Offset(0, widget.upOffset ?? 0.5), end: Offset.zero).animate(_controller),
child: FadeTransition(
opacity: Tween(begin: 0.0, end: 1.0).animate(_controller), child: widget.child));
}
}
方法二:Stack布局+AnimatedBuilder(不推荐)
- AnimatedBuilder+Tween控制值的变化
- Stack + Positioned:改变top属性值实现上组件上移的动画
- Opacity:改变opacity属性值实现透明度的变化
难点:需要知道top
的组件高度值height才能精确控制上移动画,例如如果要求组件出现时能看到一半的高度、那么top的变化就必须为height/2~0
但是目前咱没发现能build之前获取height的办法(我看有人写了个AfterLayout来做,但是我还没尝试)
只有文本可以利用TextPainter
来获取文本的size,具体代码如下
// 这里一定需要用组件的style生成一个style传入painter
final style = DefaultTextStyle.of(context).style.merge(widget.textStyle);
final painter = TextPainter(
text: TextSpan(text: widget.value, style: style),
textDirection: TextDirection.ltr,
textScaleFactor: MediaQuery.of(context).textScaleFactor,
)..layout();
final size = painter.size;
我最开始就是参考网上Flutter关于文字动画的源码写的,由于抄漏了个DefaultTextStyle.merge的逻辑,导致size的宽度一直偏小,原因是widget.textStyle没有设置letterSpacing
的,但是Flutter的Text组件画文字时、是用DefaultTextStyle加上了这个space所以实际的宽度会比计算出来的大
动画先后顺序控制
这个页面有非常多的动画,所以就需要控制动画出现的时机和先后顺序
方法一:AnimationController动画回调+主动调用forward
首先通过addListener
、addStatusListener
得到动画的回调
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: widget.duration)
..addListener(() {
widget.callback?.call(_controller);
})
..addStatusListener(widget.statusListener ?? (status) {});
}
然后通过传参的方式把动画回调暴露给外部
typedef AnimateCallback = void Function(AnimationController controller);
class UpAnimationLayout extends StatefulWidget {
final AnimateCallback? callback;
final AnimationStatusListener? statusListener;
.......
外部得到动画的回调后、通过GlobalKey
得到需要开始动画的组件、然后调用forward
开启第二个动画
final GlobalKey<_UpAnimationLayoutState> _iconsLayoutKey = GlobalKey();
void summaryAnimationCallback(controller) {
if (animationIndex < 6 && controller.value > 0.8) {
// 第一个动画进度80%时、animationIndex保证只走一次
animationIndex++;
// 通过key获取第二个组件的实例
_iconsLayoutKey.currentState?.start();
}
}
如果是StatelessWidget
的组件、可以通过currentWidget来调用
(_dateTextKey.currentWidget as UpTextAnimation).start();
void start() {
_controller.forward();
}
- 优点:组件之间彼此解耦
- 缺点:要写很多重复的代码
方法二:Interval控制每个动画的占比
Tween(begin: 0.0, end: 1.0).animate(_controller)
Flutter中给Animation
设置AnimationController
时还可以这么写
_opacityAnimation = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.5, curve: Curves.easeIn),
));
上面的Interval表示这个动画的时间段为**_controller控制的整个动画时间的0~0.5**
如果有三个不同的动画ani1、ani2、ani3,都设置Interval分别为
- ani1:0.0~0.5
- ani2:0.2~0.7
- ani3:0.5~1.0
并且他们绑定同一个AnimationController
,此时如果调用_controller.forward()
,就能够做到三个动画交错运行了
- 优点:组件之间耦合、需要统一看动画的时间
- 缺点:代码相对较少
显然通过Interval组合的动画,更适合列表一类的动画
方法三:pub上找动画插件写组合动画
flutter为啥不提供AnimationSet
类似的组件呢?网上应该有、我找了几个类似的、感觉并不太好用就没试、大家有什么推荐的么
图标动画
为了实现上面的动画、我搜索了一圈、大概准备了三种方法
- 方法一:Flutter自带的
AnimatedIcon
:自带的一些矢量图动画、但是不能定制、所以只能放弃了 - 方法二:rive,看了老半天感觉不简单、所以暂时放弃了
- 方法三:canvas画图标(实际使用):通过
CustomPaint
画图+Tween
动画实现
波浪动画
这里说下不同的地方
- 我这里是图标、所以宽度、高度需要通过参数传入,波长、波的高度都需要根据size来计算
- 我这里是画线、并且是画三条
- 同时需要用
clipPath
只保留size内的波浪线
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(widget.size, widget.size),
painter: WavePainter(
waveOffsetX: _animation,
strokeWidth: widget.strokeWidth,
waveColor: widget.color ?? Theme.of(context).primaryColor,
),
);
}
class WavePainter extends CustomPainter {
WavePainter({
required this.waveOffsetX,
required this.waveColor,
required this.strokeWidth,
}) : super(repaint: waveOffsetX);
final Animation<double> waveOffsetX;
final Color waveColor;
final double strokeWidth;
void drawWave(Canvas canvas, Size size, Paint paint) {
var waveHeight = size.height / 4;
var waveWidth = size.width;
final offsetX = waveOffsetX.value;
var offsetY = size.height * 0.2;
Path wavePath = Path();
for (int i = 0; i < 3; i++) {
Offset point1 = Offset(offsetX, offsetY);
Offset point2 = Offset(offsetX + waveWidth, offsetY);
Offset point3 = Offset(offsetX + waveWidth * 2, offsetY);
Offset point4 = Offset(offsetX + waveWidth * 3, offsetY);
Offset ct1 = Offset(offsetX + waveWidth / 2, offsetY - waveHeight);
Offset ct2 = Offset(offsetX + waveWidth * 3 / 2, offsetY + waveHeight);
Offset ct3 = Offset(offsetX + waveWidth * 5 / 2, offsetY - waveHeight);
wavePath.moveTo(point1.dx, point1.dy);
wavePath.quadraticBezierTo(ct1.dx, ct1.dy, point2.dx, point2.dy);
wavePath.quadraticBezierTo(ct2.dx, ct2.dy, point3.dx, point3.dy);
wavePath.quadraticBezierTo(ct3.dx, ct3.dy, point4.dx, point4.dy);
offsetY += waveHeight;
}
Path clipPath = Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height));
canvas.clipPath(clipPath);
canvas.drawPath(wavePath, paint);
}
@override
void paint(Canvas canvas, Size size) {
// 2. configure the paint and drawing properties
Paint paint = Paint()
..isAntiAlias = true
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..color = waveColor;
drawWave(canvas, size, paint);
}
// 7. only return true if the old progress value
// is different from the new one
@override
bool shouldRepaint(covariant WavePainter oldDelegate) {
return oldDelegate.waveOffsetX != waveOffsetX;
}
}
另外可以用这个网站调试贝塞尔曲线:cubic-bezier.com/#.46,.04,.8…
水滴动画
画水滴
这里需要用三阶贝塞尔曲线画线、不然会很突兀
一共三个曲线、分别为左上、底部、右上三条线
- 黑点为曲线的起点和终点
- 红点为控制点
flutter提供了方法来画三阶贝塞尔曲线、我简单封装下
void _cubicTo(Path path, Offset ctrl1, Offset ctrl2, Offset offset) {
path.cubicTo(ctrl1.dx, ctrl1.dy, ctrl2.dx, ctrl2.dy, offset.dx, offset.dy);
}
// dy为顶点的y坐标
Offset point1 = Offset(size.width / 2, dy * size.height);
Offset point2 = Offset(size.width * 0.2, size.height * 0.6);
Offset ct1_1 = Offset(size.width * 0.35, size.height * 0.2);
Offset ct1_2 = Offset(size.width * 0.2, size.height * 0.35);
Offset point3 = Offset(size.width * 0.8, size.height * 0.6);
Offset ct2_1 = Offset(size.width * 0.25, size.height * 1);
Offset ct2_2 = Offset(size.width * 0.75, size.height * 1);
Offset ct3_1 = Offset(size.width * 0.8, size.height * 0.35); // 对应ct1_2
Offset ct3_2 = Offset(size.width * 0.65, size.height * 0.2); // 对应ct1_1
Path path = Path()..moveTo(point1.dx, point1.dy);
_cubicTo(path, ct1_1, ct1_2, point2);
_cubicTo(path, ct2_1, ct2_2, point3);
_cubicTo(path, ct3_1, ct3_2, point1);
canvas.drawPath(path, paint);
画中间圆弧
这个就不用贝塞尔曲线了,直接用圆或者椭圆就行
确定好中心点和曲线的起始角度即可(注意flutter圆的起始度数为90度)
代码很简单吧,顺便提一下神奇的copilot、这里度数的计算、我就在注释里写上了起点、终点,ai就帮我想好了度数的计算了。这个操作在动画代码里我用到了很多次
动画控制
- 水滴上升动画:控制顶部点y的坐标即可
- 圆弧动画:控制起始角度即可
- 圆弧动画类似荡秋千、每次水滴的顶点到最高时、秋千也到最高,由于有左右两次最高的时间点、所以圆弧动画的总时间是水滴动画总时间的2倍
这里我没找到好办法直接实现2倍的操作、我是这么写的
@override
void initAnimations() {
_offsetAnimation = Tween(begin: 0.0, end: 1.0).animate(_controller);
_startAngleAnimation = Tween(begin: 0.0, end: 1.0)
.animate(CurvedAnimation(parent: _controller, curve: Curves.ease));
}
因为要做循环、所以不能直接通过interval或者时间控制,下面的代码其实还是ai帮我写好的
double dy;
// 由于startAngle的动画时间是offset动画时间的两倍,这里对半处理
if (firstOffsetYFactor.value < 0.5) {
dy = firstOffsetYFactor.value * 0.2;
} else {
dy = (1 - firstOffsetYFactor.value) * 0.2;
}
眼睛动画
- 通过两条三阶贝塞尔曲线画眼眶
- 通过圆画一个眼珠
- 眨眼效果:通过
clipPath
保证眼珠在眼眶之内 - 动画控制:改变贝塞尔曲线控制点y轴坐标即可
动画循环
flutter没有提供设置动画循环的api,所以还得自己写 思路也不复杂
- 动画结束后:通过AnimationController.addStatusListener监听status实现
- 再次开启动画
- reset:从0到1
- reverse:从1到0
我这里实际情况是眼珠和水滴需要reverse、波浪线需要reset
因为我需要的循环动画有点多、所以简单封装了一下
mixin LoopAnimation<T extends StatefulWidget> on State<T> {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = initAnimationController();
// 循环动画
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
if (isResetAnimation()) {
_controller.reset();
} else {
_controller.reverse();
}
} else if (status == AnimationStatus.dismissed) {
_controller.forward();
}
});
initAnimations();
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
bool isResetAnimation() => true;
AnimationController initAnimationController();
void initAnimations();
}
页面翻转动画
参考的这个源码:github.com/aeyrium/cub…
大致原理也不复杂,咱画个图就明白了
关键代码
- 如果是页面内反转:需要利用
PageView
- 如果是页面间跳转:需要利用
PageRouteBuilder
- 旋转代码
具体代码咱就不贴了,我也是搬的,他这个源码是flutter2+的,所以要稍微改一下空安全相关代码