Flutter canvas+animation实现圆形波浪进度球

前言:笔者近期实在太忙,加上想不到很好的可以编写的内容(其实插件开发系列只写了一篇),确实鸽了大家好久。接下来我的重心会偏向酷炫UI的实现,把canvas、动画、Render层玩透先。

灵感来源

作为一个前端开发者,遇到动画特效总是想让美工输入json文件,再Lottie加载下,效率性能两不误。直到那天我看到一个波浪进度球,顿时实在想不出什么理由可以糊弄成GIF图或者json文件,毕竟加载进度完全是需要代码精准控制的。der~还是自己实现玩一玩吧。

效果

笔者花了周日一整个下午的时间,配着祖传工夫茶,终于撸出个像样的家伙,性能debug环境下都能稳稳保持在60帧左右。查看源代码点这里,预览效果见下图:

效果图

实现步骤

我将这个动效分为3层canvas:圆形背景、圆弧进度条、两层波浪;3个动画:圆弧前进动画、两层波浪移动的动画。

  1. 首先绘制圆形背景,很简单,画圆即可,这一层canvas是不需要重新绘制的;
import 'package:flutter/material.dart';

class RoundBasePainter extends CustomPainter {
  final Color color;

  RoundBasePainter(this.color);

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()
      ..isAntiAlias = true
      ..style = PaintingStyle.stroke
      ..strokeWidth = 7.0
      ..color = color;
    //画进度条圆框背景
    canvas.drawCircle(size.center(Offset.zero), size.width / 2, paint);
    //保存画布状态
    canvas.save();
    //恢复画布状态
    canvas.restore();
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
  1. 绘制圆弧进度条,这里需要理解下我们的起点是在-90°开始的,同时需要将进度转化为角度进行圆弧的绘制;
import 'dart:math';

import 'package:flutter/material.dart';

class RoundProgressPainter extends CustomPainter {
  final Color color;
  final double progress;

  RoundProgressPainter(this.color, this.progress);

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()
      ..isAntiAlias = true
      ..style = PaintingStyle.stroke
      ..strokeWidth = 7.0
      ..color = color;
    // 画圆弧
    canvas.drawArc(
        Rect.fromCircle(
            center: size.center(Offset.zero), radius: size.width / 2),
        -pi / 2, // 起点是-90°
        pi * 2 * progress, // 进度*360°
        false,
        paint);
    canvas.save();
    canvas.restore();
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}
  1. 绘制波浪,这里的原理是:通过贝塞尔曲线实现曲线的,再通过path连接成完整的区域(见图一),然后把这块区域通过clip裁剪成圆形即可;


    实际上波浪的区域

    代码一睹为快:

import 'package:flutter/material.dart';

class WavyPainter extends CustomPainter {
  // 波浪的曲度
  final double waveHeight;
  // 进度 [0-1]
  final double progress;
  // 对波浪区域进行X轴方向的偏移,实现滚动效果
  final double offsetX;

  final Color color;

  WavyPainter(this.progress, this.offsetX, this.color, {this.waveHeight = 24});

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()
      ..isAntiAlias = true
      ..style = PaintingStyle.fill
      ..strokeWidth = 1.5
      ..color = color;
    drawWave(canvas, size.center(Offset(0, 0)), size.width / 2, paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }

  void drawWave(Canvas canvas, Offset center, double radius, Paint paint) {
    // 圆形裁剪
    canvas.save();
    Path clipPath = Path()
      ..addOval(Rect.fromCircle(center: center, radius: radius));
    canvas.clipPath(clipPath);

    // 反向计算点的纵坐标
    double wavePointY = (1 - progress) * radius * 2;

    // point3为中心点,波浪的直径为圆的半径,一共5个点,加上两个闭环点(p6、p7)
    Offset point1 = Offset(center.dx - radius * 3 + offsetX, wavePointY);
    Offset point2 = Offset(center.dx - radius * 2 + offsetX, wavePointY);
    Offset point3 = Offset(center.dx - radius + offsetX, wavePointY);
    Offset point4 = Offset(center.dx + offsetX, wavePointY);
    Offset point5 = Offset(center.dx + radius + offsetX, wavePointY);

    Offset point6 = Offset(point5.dx, center.dy + radius + waveHeight);
    Offset point7 = Offset(point1.dx, center.dy + radius + waveHeight);

    // 贝塞尔曲线控制点
    Offset c1 =
        Offset(center.dx - radius * 2.5 + offsetX, wavePointY + waveHeight);
    Offset c2 =
        Offset(center.dx - radius * 1.5 + offsetX, wavePointY - waveHeight);
    Offset c3 =
        Offset(center.dx - radius * 0.5 + offsetX, wavePointY + waveHeight);
    Offset c4 =
        Offset(center.dx + radius * 0.5 + offsetX, wavePointY - waveHeight);

    // 连接贝塞尔曲线
    Path wavePath = Path()
      ..moveTo(point1.dx, point1.dy)
      ..quadraticBezierTo(c1.dx, c1.dy, point2.dx, point2.dy)
      ..quadraticBezierTo(c2.dx, c2.dy, point3.dx, point3.dy)
      ..quadraticBezierTo(c3.dx, c3.dy, point4.dx, point4.dy)
      ..quadraticBezierTo(c4.dx, c4.dy, point5.dx, point5.dy)
      ..lineTo(point6.dx, point6.dy)
      ..lineTo(point7.dx, point7.dy)
      ..close();

    // 绘制
    canvas.drawPath(wavePath, paint);
    canvas.restore();
  }
}
  1. 添加动画。重点讲解下波浪的动画效果,其实就是上面的贝塞尔曲线区域,进行X轴方向的重复匀速移动,加上贝塞尔曲线的效果,就可以产生上下起伏的波浪效果。且通过下图,可以确定平移的距离是圆形的直径
    波浪区域数学模型

    笔者创建的是package,下面的代码是真正暴露给调用方使用的控件的实现,同时也实现了所有的动画。
library round_wavy_progress;

import 'package:flutter/material.dart';
import 'package:round_wavy_progress/painter/round_base_painter.dart';
import 'package:round_wavy_progress/painter/round_progress_painter.dart';
import 'package:round_wavy_progress/painter/wavy_painter.dart';
import 'package:round_wavy_progress/progress_controller.dart';

class RoundWavyProgress extends StatefulWidget {
  RoundWavyProgress(this.progress, this.radius, this.controller,
      {Key? key,
      this.mainColor,
      this.secondaryColor,
      this.roundSideColor = Colors.grey,
      this.roundProgressColor = Colors.white})
      : super(key: key);

  final double progress;
  final double radius;
  final ProgressController controller;
  final Color? mainColor;
  final Color? secondaryColor;
  final Color roundSideColor;
  final Color roundProgressColor;

  @override
  _RoundWavyProgressState createState() => _RoundWavyProgressState();
}

class _RoundWavyProgressState extends State<RoundWavyProgress>
    with TickerProviderStateMixin {
  late AnimationController wareController;
  late AnimationController mainController;
  late AnimationController secondController;

  late Animation<double> waveAnimation;
  late Animation<double> mainAnimation;
  late Animation<double> secondAnimation;

  double currentProgress = 0.0;

  @override
  void initState() {
    super.initState();
    widget.controller.stream.listen((event) {
      print(event);
      wareController.reset();
      waveAnimation = Tween(begin: currentProgress, end: event as double)
          .animate(wareController);
      currentProgress = event;
      wareController.forward();
    });

    wareController = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 1200),
    );

    mainController = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 3200),
    );

    secondController = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 1800),
    );

    waveAnimation = Tween(begin: currentProgress, end: widget.progress)
        .animate(wareController);
    mainAnimation =
        Tween(begin: 0.0, end: widget.radius * 2).animate(mainController);
    secondAnimation =
        Tween(begin: widget.radius * 2, end: 0.0).animate(secondController);

    wareController.forward();
    mainController.repeat();
    secondController.repeat();
  }

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(builder: (context, constraints) {
      final viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
      return AnimatedBuilder(
          animation: mainAnimation,
          builder: (BuildContext ctx, Widget? child) {
            return AnimatedBuilder(
                animation: secondAnimation,
                builder: (BuildContext ctx, Widget? child) {
                  return AnimatedBuilder(
                      animation: waveAnimation,
                      builder: (BuildContext ctx, Widget? child) {
                        return Stack(
                          children: [
                            RepaintBoundary(
                              child: CustomPaint(
                                size: viewportSize,
                                painter: WavyPainter(
                                    waveAnimation.value,
                                    mainAnimation.value,
                                    widget.mainColor ??
                                        Theme.of(context).primaryColor),
                                child: RepaintBoundary(
                                  child: CustomPaint(
                                    size: viewportSize,
                                    painter: WavyPainter(
                                        waveAnimation.value,
                                        secondAnimation.value,
                                        widget.secondaryColor ??
                                            Theme.of(context)
                                                .primaryColor
                                                .withOpacity(0.5)),
                                    child: RepaintBoundary(
                                      child: CustomPaint(
                                        size: viewportSize,
                                        painter: RoundBasePainter(
                                            widget.roundSideColor),
                                        child: RepaintBoundary(
                                          child: CustomPaint(
                                            size: viewportSize,
                                            painter: RoundProgressPainter(
                                                widget.roundProgressColor,
                                                waveAnimation.value),
                                          ),
                                        ),
                                      ),
                                    ),
                                  ),
                                ),
                              ),
                            ),
                            Align(
                              alignment: Alignment.center,
                              child: Text(
                                '${(waveAnimation.value * 100).toStringAsFixed(2)}%',
                                style: TextStyle(
                                    fontSize: 18,
                                    color: widget.roundProgressColor,
                                    fontWeight: FontWeight.bold),
                              ),
                            ),
                          ],
                        );
                      });
                });
          });
    });
  }
}

这里代码也不细讲,没什么难度。主要跟大家说一下RepaintBoundary的好处,使用这个控件包裹下,可以控制其较小颗粒度的重绘。(具体不扩展,有需要请查看:https://pub.flutter-io.cn/documentation/flutter_for_web/latest/widgets/RepaintBoundary-class.html
同时,这里AnimatedBuilder 的嵌套是真的恶心,由于时间比较急,我没有去查看是否有更好的实现方式,但至少这个实现方法在效果、性能都非常不错。

性能还是不错的

写在最后

感谢小伙伴看到了最后,这个进度球已经上传到个人GitHub,欢迎fork和star;我本周会抽时间继续优化,抽象出更简洁的api,并且发布到pub上;
同时也有更加酷炫的UI正在编写中,我也会尽力抽出空闲时间去编写更好的文章与大家交流,加油~~~

小弟班门弄斧,希望能一起学习进步!!!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,386评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,939评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,851评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,953评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,971评论 5 369
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,784评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,126评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,765评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,148评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,744评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,858评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,479评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,080评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,053评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,278评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,245评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,590评论 2 343

推荐阅读更多精彩内容