likes
comments
collection
share

Flutter练习第一弹-酷炫入场动画

作者站长头像
站长
· 阅读数 7
Flutter练习第一弹-酷炫入场动画

完整效果如上图

作为一个老年Android,我已经一年多没写Android代码了,所以最近在复健、但是Android实在没搞头,所以决定学下Flutter。学Flutter大概一个多星期、这个算是咱第一个比较完整的功能代码了,在这里给大家分享下我的思路和关键代码

设计稿来源

秉承着能搬就搬的原则,咱从dribbble上搜索weather关键字、找到了这个酷炫的效果图,于是看看能不能复刻出来 # Mobile | Weather app

Flutter练习第一弹-酷炫入场动画

布局实现

Flutter练习第一弹-酷炫入场动画

代码不复杂,这里我就不详细讲了。作为Flutter新手的我、能快速搞定布局、下面两个工具真是出了大力。

这里我随便举个copilot牛皮的例子

Flutter练习第一弹-酷炫入场动画

上面这个布局,我才写了个类名

class ForecastLayout extends StatefulWidget {

接下来神奇的copilot就给我生成了下面一大堆代码

Flutter练习第一弹-酷炫入场动画

数据类都给我写好了,他为什么知道我要用这三个字段、是因为forecast是预测的意思么!!! 这里ai知道我在写天气相关的ui、给我生成了day和temperature两个字段就算了,竟然还知道给我加个icon,你说神奇不神奇!!!

Flutter练习第一弹-酷炫入场动画

除了边框、间距、背景色基本都和最终结果八九不离十

上升动画

Flutter练习第一弹-酷炫入场动画

入场动画中大部分都是这种上升+渐现的组合动画,Flutter提供了很多简化动画写法的封装类,由于咱刚学也在摸索,所以写了好几种实现

方法一:SlideTransition+FadeTransition(推荐)

  1. SlideTransition 控制y方向上移动的距离,这里的0.5就表示child高度的一半
  2. FadeTransition 控制child的透明度从0到1

Flutter练习第一弹-酷炫入场动画

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(不推荐)

  1. AnimatedBuilder+Tween控制值的变化
  2. Stack + Positioned:改变top属性值实现上组件上移的动画
  3. 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

首先通过addListeneraddStatusListener得到动画的回调

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练习第一弹-酷炫入场动画

为了实现上面的动画、我搜索了一圈、大概准备了三种方法

  • 方法一:Flutter自带的AnimatedIcon:自带的一些矢量图动画、但是不能定制、所以只能放弃了
  • 方法二:rive,看了老半天感觉不简单、所以暂时放弃了
  • 方法三:canvas画图标(实际使用):通过CustomPaint画图+Tween动画实现

波浪动画

这里说下不同的地方

  • 我这里是图标、所以宽度、高度需要通过参数传入,波长、波的高度都需要根据size来计算
  • 我这里是画线、并且是画三条
  • 同时需要用clipPath只保留size内的波浪线

Flutter练习第一弹-酷炫入场动画

Flutter练习第一弹-酷炫入场动画

@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练习第一弹-酷炫入场动画

这里需要用三阶贝塞尔曲线画线、不然会很突兀

一共三个曲线、分别为左上、底部、右上三条线

  • 黑点为曲线的起点和终点
  • 红点为控制点

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练习第一弹-酷炫入场动画

这个就不用贝塞尔曲线了,直接用圆或者椭圆就行

确定好中心点和曲线的起始角度即可(注意flutter圆的起始度数为90度

Flutter练习第一弹-酷炫入场动画

代码很简单吧,顺便提一下神奇的copilot、这里度数的计算、我就在注释里写上了起点、终点,ai就帮我想好了度数的计算了。这个操作在动画代码里我用到了很多次

动画控制

Flutter练习第一弹-酷炫入场动画

  • 水滴上升动画:控制顶部点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;
}

眼睛动画

Flutter练习第一弹-酷炫入场动画

  • 通过两条三阶贝塞尔曲线画眼眶
  • 通过圆画一个眼珠
  • 眨眼效果:通过clipPath保证眼珠在眼眶之内
  • 动画控制:改变贝塞尔曲线控制点y轴坐标即可

Flutter练习第一弹-酷炫入场动画

动画循环

flutter没有提供设置动画循环的api,所以还得自己写 思路也不复杂

  • 动画结束后:通过AnimationController.addStatusListener监听status实现
  • 再次开启动画
    • reset:从0到1
    • reverse:从1到0

Flutter练习第一弹-酷炫入场动画

我这里实际情况是眼珠和水滴需要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…

大致原理也不复杂,咱画个图就明白了

Flutter练习第一弹-酷炫入场动画

关键代码

  • 如果是页面内反转:需要利用PageView
  • 如果是页面间跳转:需要利用PageRouteBuilder
  • 旋转代码

Flutter练习第一弹-酷炫入场动画

具体代码咱就不贴了,我也是搬的,他这个源码是flutter2+的,所以要稍微改一下空安全相关代码