Flutter canvas+animation實(shí)現(xiàn)圓形波浪進(jìn)度球

前言:筆者近期實(shí)在太忙,加上想不到很好的可以編寫的內(nèi)容(其實(shí)插件開發(fā)系列只寫了一篇),確實(shí)鴿了大家好久。接下來我的重心會偏向酷炫UI的實(shí)現(xiàn),把canvas、動畫、Render層玩透先。

靈感來源

作為一個前端開發(fā)者,遇到動畫特效總是想讓美工輸入json文件,再Lottie加載下,效率性能兩不誤。直到那天我看到一個波浪進(jìn)度球,頓時實(shí)在想不出什么理由可以糊弄成GIF圖或者json文件,畢竟加載進(jìn)度完全是需要代碼精準(zhǔn)控制的。der~還是自己實(shí)現(xiàn)玩一玩吧。

效果

筆者花了周日一整個下午的時間,配著祖?zhèn)鞴し虿瑁K于擼出個像樣的家伙,性能debug環(huán)境下都能穩(wěn)穩(wěn)保持在60幀左右。查看源代碼點(diǎn)這里,預(yù)覽效果見下圖:

效果圖

實(shí)現(xiàn)步驟

我將這個動效分為3層canvas:圓形背景、圓弧進(jìn)度條、兩層波浪;3個動畫:圓弧前進(jìn)動畫、兩層波浪移動的動畫。

  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;
    //畫進(jìn)度條圓框背景
    canvas.drawCircle(size.center(Offset.zero), size.width / 2, paint);
    //保存畫布狀態(tài)
    canvas.save();
    //恢復(fù)畫布狀態(tài)
    canvas.restore();
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
  1. 繪制圓弧進(jìn)度條,這里需要理解下我們的起點(diǎn)是在-90°開始的,同時需要將進(jìn)度轉(zhuǎn)化為角度進(jìn)行圓弧的繪制;
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, // 起點(diǎn)是-90°
        pi * 2 * progress, // 進(jìn)度*360°
        false,
        paint);
    canvas.save();
    canvas.restore();
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}
  1. 繪制波浪,這里的原理是:通過貝塞爾曲線實(shí)現(xiàn)曲線的,再通過path連接成完整的區(qū)域(見圖一),然后把這塊區(qū)域通過clip裁剪成圓形即可;


    實(shí)際上波浪的區(qū)域

    代碼一睹為快:

import 'package:flutter/material.dart';

class WavyPainter extends CustomPainter {
  // 波浪的曲度
  final double waveHeight;
  // 進(jìn)度 [0-1]
  final double progress;
  // 對波浪區(qū)域進(jìn)行X軸方向的偏移,實(shí)現(xiàn)滾動效果
  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);

    // 反向計算點(diǎn)的縱坐標(biāo)
    double wavePointY = (1 - progress) * radius * 2;

    // point3為中心點(diǎn),波浪的直徑為圓的半徑,一共5個點(diǎn),加上兩個閉環(huán)點(diǎn)(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);

    // 貝塞爾曲線控制點(diǎn)
    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. 添加動畫。重點(diǎn)講解下波浪的動畫效果,其實(shí)就是上面的貝塞爾曲線區(qū)域,進(jìn)行X軸方向的重復(fù)勻速移動,加上貝塞爾曲線的效果,就可以產(chǎn)生上下起伏的波浪效果。且通過下圖,可以確定平移的距離是圓形的直徑。
    波浪區(qū)域數(shù)學(xué)模型

    筆者創(chuàng)建的是package,下面的代碼是真正暴露給調(diào)用方使用的控件的實(shí)現(xiàn),同時也實(shí)現(xiàn)了所有的動畫。
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),
                              ),
                            ),
                          ],
                        );
                      });
                });
          });
    });
  }
}

這里代碼也不細(xì)講,沒什么難度。主要跟大家說一下RepaintBoundary的好處,使用這個控件包裹下,可以控制其較小顆粒度的重繪。(具體不擴(kuò)展,有需要請查看:https://pub.flutter-io.cn/documentation/flutter_for_web/latest/widgets/RepaintBoundary-class.html
同時,這里AnimatedBuilder 的嵌套是真的惡心,由于時間比較急,我沒有去查看是否有更好的實(shí)現(xiàn)方式,但至少這個實(shí)現(xiàn)方法在效果、性能都非常不錯。

性能還是不錯的

寫在最后

感謝小伙伴看到了最后,這個進(jìn)度球已經(jīng)上傳到個人GitHub,歡迎fork和star;我本周會抽時間繼續(xù)優(yōu)化,抽象出更簡潔的api,并且發(fā)布到pub上;
同時也有更加酷炫的UI正在編寫中,我也會盡力抽出空閑時間去編寫更好的文章與大家交流,加油~~~

小弟班門弄斧,希望能一起學(xué)習(xí)進(jìn)步!??!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容