Flutter 實戰 - 用貝塞爾曲線畫一個帶文字的波浪球 Widget

葉志陳發表於2019-10-27

Flutter 實戰 - 用貝塞爾曲線畫一個帶文字的波浪球 Widget

flutter 中的自定義 Widget 算作是 flutter 體系中比較高階的知識點之一了,相當於原生開發中的自定義 View,以我個人的感受來說,自定義 widget 的難度要低於自定義 View,不過由於當前 flutter 的開源庫還不算多豐富,所以有些效果還是需要開發者自己動手來實現,而本篇文章就來介紹如何用 flutter 來實現一個帶文字的波浪球 Widget,實現的的效果如下所示:

Flutter 實戰 - 用貝塞爾曲線畫一個帶文字的波浪球 Widget

原始碼點選這裡下載:github.com/leavesC/flu…

先來總結下該 WaveLoadingWidget 的特點,這樣才能歸納出實現該效果所需的步驟

  1. widget 的主體是一個不規則的半圓,頂部以類似於波浪的形式從左往右上下波動執行
  2. 球形波浪可以自定義顏色,此處以 waveColor 命名
  3. 波浪的起伏線將嵌入的文字分為上下兩種顏色,上邊的文字顏色以 backgroundColor 命名,下邊的文字顏色以 foregroundColor 命名,文字的顏色一直在動態變化中

雖然波浪是不斷運動的,但只要能夠繪製出其中一幀的圖形,其動態效果就能通過不斷改變波浪的位置引數來完成,所以這裡先把該 widget 當成靜態的,先實現其靜態效果即可

將繪製步驟拆解為以下幾步:

  1. 繪製顏色為 backgroundColor 的文字,將其繪製在 canvas 的最底層
  2. 根據 widget 的寬高資訊構建一個不超出範圍的最大圓形路徑 circlePath
  3. 以 circlePath 的水平中間線作為波浪的起伏線,在起伏線的上邊和下邊分別利用貝塞爾曲線繪製一段連續的波浪 path,將 path 的首尾兩端以矩形的形式連線在一起,構成 wavePath,wavePath 的底部會與 circlePath 的底部相交於一點
  4. 取 circlePath 和 wavePath 的交集 targetPath,用 waveColor 填充, 此時就得到了半圓形的球形波浪了
  5. 利用 canvas.clipPath(targetPath) 方法裁切畫布,再繪製顏色為 foregroundColor 的文字,此時繪製的 foregroundColor 文字只會顯示 targetPath 範圍內的部分,從而使兩次不同時間繪製的文字重疊在了一起,得到了有不同顏色範圍的文字
  6. 利用 flutter 動畫不斷改變 wavePath 的起始點的 X 座標,同時重新繪製 UI,從而得到波浪不斷從左往右前進的效果

現在就來一步步實現以上的繪製步驟吧

一、初始化畫筆

flutter 通過抽象類 CustomPainter 為開發者提供了自繪 UI 的入口,其內部的抽象方法 void paint(Canvas canvas, Size size) 提供了畫布物件 canvas 以及包含 widget 寬高資訊的 size 物件

此處就來繼承 CustomPainter 類,初始化畫筆物件以及各個配置引數(要繪製的文字,顏色值等)

class WaveLoadingPainter extends CustomPainter {
  //如果外部沒有指定顏色值,則使用此預設顏色值
  static final Color defaultColor = Colors.lightBlue;

  //畫筆物件
  var _paint = Paint();

  //圓形路徑
  Path _circlePath = Path();

  //波浪路徑
  Path _wavePath = Path();

  //要顯示的文字
  final String text;

  //字型大小
  final double fontSize;

  final Color backgroundColor;

  final Color foregroundColor;

  final Color waveColor;

  WaveLoadingPainter(
      {this.text,
      this.fontSize,
      this.backgroundColor,
      this.foregroundColor,
      this.waveColor}) {
    _paint
      ..isAntiAlias = true
      ..style = PaintingStyle.fill
      ..strokeWidth = 3
      ..color = waveColor ?? defaultColor;
  }

  @override
  void paint(Canvas canvas, Size size) {
    
  }

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

複製程式碼

二、繪製 backgroundColor 文字

flutter 的 canvas 物件沒有提供直接 drawText 的 API,其繪製文字的步驟相對原生的自定義 View 要比較麻煩

@override
  void paint(Canvas canvas, Size size) {
    double side = min(size.width, size.height);
    double radius = side / 2.0;

    _drawText(canvas: canvas, side: side, colors: backgroundColor);
      
    ···
  }

  void _drawText({Canvas canvas, double side, Color colors}) {
    ParagraphBuilder pb = ParagraphBuilder(ParagraphStyle(
      textAlign: TextAlign.center,
      fontStyle: FontStyle.normal,
      fontSize: fontSize ?? 0,
    ));
    pb.pushStyle(ui.TextStyle(color: colors ?? defaultColor));
    pb.addText(text);
    ParagraphConstraints pc = ParagraphConstraints(width: fontSize ?? 0);
    Paragraph paragraph = pb.build()..layout(pc);
    canvas.drawParagraph(
        paragraph,
        Offset(
            (side - paragraph.width) / 2.0, (side - paragraph.height) / 2.0));
  }
複製程式碼

Flutter 實戰 - 用貝塞爾曲線畫一個帶文字的波浪球 Widget

三、構建圓形路徑 circlePath

取 widget 的寬和高的最小值作為圓的直徑大小,以此構建出一個不超出 widget 範圍的最大圓形路徑

 @override
  void paint(Canvas canvas, Size size) {
    double side = min(size.width, size.height);
    double radius = side / 2.0;

    _drawText(canvas: canvas, side: side, colors: backgroundColor);
      
    _circlePath.reset();
    _circlePath.addArc(Rect.fromLTWH(0, 0, side, side), 0, 2 * pi);

    ···
  }
複製程式碼

四、利用貝塞爾曲線繪製波浪線

此處波浪的寬度和高度就根據一個固定的比例值來求值,以 _circlePath 的中間分隔線作為水平線,在水平線上下根據貝塞爾曲線繪製出連續的波浪線

  @override
  void paint(Canvas canvas, Size size) {
    double side = min(size.width, size.height);
    double radius = side / 2.0;

    _drawText(canvas: canvas, side: side, colors: backgroundColor);

    _circlePath.reset();
    _circlePath.addArc(Rect.fromLTWH(0, 0, side, side), 0, 2 * pi);

    double waveWidth = side * 0.8;
    double waveHeight = side / 6;
    _wavePath.reset();
    _wavePath.moveTo(-waveWidth, radius);
    for (double i = -waveWidth; i < side; i += waveWidth) {
      _wavePath.relativeQuadraticBezierTo(
          waveWidth / 4, -waveHeight, waveWidth / 2, 0);
      _wavePath.relativeQuadraticBezierTo(
          waveWidth / 4, waveHeight, waveWidth / 2, 0);
    }

    //為了方便讀者理解,這裡把路徑繪製出來,實際上不需要
    canvas.drawPath(_wavePath, _paint);

  }
複製程式碼

Flutter 實戰 - 用貝塞爾曲線畫一個帶文字的波浪球 Widget

此時繪製的曲線還處於非閉合狀態,需要將 _wavePath 的首尾兩端連線起來,這樣才可以和 _circlePath 做交集

    _wavePath.relativeLineTo(0, radius);
    _wavePath.lineTo(-waveWidth, side);
    _wavePath.close();
複製程式碼

_wavePath 閉合後,此時繪製出來的圖形就如下所示

Flutter 實戰 - 用貝塞爾曲線畫一個帶文字的波浪球 Widget

五、取 _circlePath 和 _wavePath 的交集

_circlePath 和 _wavePath 的交集就是一個半圓形波浪了

    var combine = Path.combine(PathOperation.intersect, _circlePath, _wavePath);
    canvas.drawPath(combine, _paint);

    //為了方便讀者理解,這裡把路徑繪製出來,實際上不需要
    canvas.drawPath(combine, _paint);
複製程式碼

Flutter 實戰 - 用貝塞爾曲線畫一個帶文字的波浪球 Widget

六、裁切畫布並繪製頂層文字

文字的顏色是分為上下兩部分的,foregroundColor 顏色的文字不需要顯示上半部分,所以在繪製 foregroundColor 文字的時候需要把上半部分文字給裁切掉,使兩次不同時間繪製的文字重疊在了一起,得到了有不同顏色範圍的文字

    canvas.clipPath(combine);
    _drawText(canvas: canvas, side: side, colors: foregroundColor);
複製程式碼

Flutter 實戰 - 用貝塞爾曲線畫一個帶文字的波浪球 Widget

八、新增動畫

現在已經繪製好了單獨一幀時的效果圖了,可以考慮使 widget 動起來了

只要不斷改變貝塞爾曲線的起始點座標,使之不斷從左往右移動,就可以營造出波浪從左往右前進的效果了。WaveLoadingPainter 只負責根據外部傳入的動畫值 animatedValue 來繪製 UI,構造 animatedValue 的邏輯則由外部的 _WaveLoadingWidgetState 進行處理,這裡規定 animatedValue 的值是從 0 遞增到 1,在開始構建 _wavePath 前只需要移動其起始座標點即可

 @override
  void paint(Canvas canvas, Size size) {
    double side = min(size.width, size.height);
    double radius = side / 2.0;

    _drawText(canvas: canvas, side: side, colors: backgroundColor);

    _circlePath.reset();
    _circlePath.addArc(Rect.fromLTWH(0, 0, side, side), 0, 2 * pi);

    double waveWidth = side * 0.8;
    double waveHeight = side / 6;
    _wavePath.reset();
    _wavePath.moveTo((animatedValue - 1) * waveWidth, radius);
    for (double i = -waveWidth; i < side; i += waveWidth) {
      _wavePath.relativeQuadraticBezierTo(
          waveWidth / 4, -waveHeight, waveWidth / 2, 0);
      _wavePath.relativeQuadraticBezierTo(
          waveWidth / 4, waveHeight, waveWidth / 2, 0);
    }
    _wavePath.relativeLineTo(0, radius);
    _wavePath.lineTo(-waveWidth, side);
    _wavePath.close();

    var combine = Path.combine(PathOperation.intersect, _circlePath, _wavePath);
    canvas.drawPath(combine, _paint);

    canvas.clipPath(combine);
    _drawText(canvas: canvas, side: side, colors: foregroundColor);
  }
複製程式碼
class _WaveLoadingWidgetState extends State<WaveLoadingWidget>
    with SingleTickerProviderStateMixin {
  final String text;

  final double fontSize;

  final Color backgroundColor;

  final Color foregroundColor;

  final Color waveColor;

  AnimationController controller;

  Animation<double> animation;

  _WaveLoadingWidgetState(
      {@required this.text,
      @required this.fontSize,
      @required this.backgroundColor,
      @required this.foregroundColor,
      @required this.waveColor});

  @override
  void initState() {
    super.initState();
    controller =
        AnimationController(duration: const Duration(seconds: 1), vsync: this);
    controller.addStatusListener((status) {
      switch (status) {
        case AnimationStatus.dismissed:
          print("dismissed");
          break;
        case AnimationStatus.forward:
          print("forward");
          break;
        case AnimationStatus.reverse:
          print("reverse");
          break;
        case AnimationStatus.completed:
          print("completed");
          break;
      }
    });

    animation = Tween(
      begin: 0.0,
      end: 1.0,
    ).animate(controller)
      ..addListener(() {
        setState(() => {});
      });
    controller.repeat();
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: WaveLoadingPainter(
        text: text,
        fontSize: fontSize,
        animatedValue: animation.value,
        backgroundColor: backgroundColor,
        foregroundColor: foregroundColor,
        waveColor: waveColor,
      ),
    );
  }
}
複製程式碼

九、包裹為 StatefulWidget 並使用

之後只要將 WaveLoadingPainter 包裹到 StatefulWidget 中即可,在 StatefulWidget 中開放可以自定義配置的引數就可以了

class WaveLoadingWidget extends StatefulWidget {
  final String text;

  final double fontSize;

  final Color backgroundColor;

  final Color foregroundColor;

  final Color waveColor;

  WaveLoadingWidget(
      {@required this.text,
      @required this.fontSize,
      @required this.backgroundColor,
      @required this.foregroundColor,
      @required this.waveColor}) {
    assert(text != null && text.length == 1);
    assert(fontSize != null && fontSize > 0);
  }

  @override
  _WaveLoadingWidgetState createState() => _WaveLoadingWidgetState(
        text: text,
        fontSize: fontSize,
        backgroundColor: backgroundColor,
        foregroundColor: foregroundColor,
        waveColor: waveColor,
      );
}
複製程式碼

使用方式就類似於一般的系統 widget

		Container(
            width: 300,
            height: 300,
            child: WaveLoadingWidget(
              text: "鍥",
              fontSize: 215,
              backgroundColor: Colors.lightBlue,
              foregroundColor: Colors.white,
              waveColor: Colors.lightBlue,
            ),
          ),
          Container(
            width: 250,
            height: 250,
            child: WaveLoadingWidget(
              text: "而",
              fontSize: 175,
              backgroundColor: Colors.indigoAccent,
              foregroundColor: Colors.white,
              waveColor: Colors.indigoAccent,
            ),
          ),
複製程式碼

原始碼點選這裡下載:github.com/leavesC/flu…

此外該專案也提供了 N 多個常用 Widget 和自定義 Widget 的使用及實現方法,涵蓋了系統 Widget 、佈局容器、動畫、高階功能、自定義 Widget 等內容,歡迎 star

相關文章