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

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

flutter 中的自定義 Widget 算做是 flutter 體系中比較高階的知識點之一了,至關於原生開發中的自定義 View,以我我的的感覺來講,自定義 widget 的難度要低於自定義 View,不過因爲當前 flutter 的開源庫還不算多豐富,因此有些效果仍是須要開發者本身動手來實現,而本篇文章就來介紹如何用 flutter 來實現一個帶文本的波浪球 Widget,實現的的效果以下所示:git

源代碼點擊這裏下載:github.com/leavesC/flu…github

先來總結下該 WaveLoadingWidget 的特色,這樣才能概括出實現該效果所需的步驟canvas

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

雖然波浪是不斷運動的,但只要可以繪製出其中一幀的圖形,其動態效果就能經過不斷改變波浪的位置參數來完成,因此這裏先把該 widget 當成靜態的,先實現其靜態效果便可ide

將繪製步驟拆解爲如下幾步:佈局

  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,從而獲得波浪不斷從左往右前進的效果

如今就來一步步實現以上的繪製步驟吧字體

1、初始化畫筆

flutter 經過抽象類 CustomPainter 爲開發者提供了自繪 UI 的入口,其內部的抽象方法 void paint(Canvas canvas, Size size) 提供了畫布對象 canvas 以及包含 widget 寬高信息的 size 對象動畫

此處就來繼承 CustomPainter 類,初始化畫筆對象以及各個配置參數(要繪製的文本,顏色值等)ui

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;
  }
}

複製代碼

2、繪製 backgroundColor 文本

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

@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));
  }
複製代碼

3、構建圓形路徑 circlePath

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

@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);

    ···
  }
複製代碼

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

此處波浪的寬度和高度就根據一個固定的比例值來求值,以 _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);

  }
複製代碼

此時繪製的曲線還處於非閉合狀態,須要將 _wavePath 的首尾兩端鏈接起來,這樣才能夠和 _circlePath 作交集

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

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

5、取 _circlePath 和 _wavePath 的交集

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

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

    //爲了方便讀者理解,這裏把路徑繪製出來,實際上不須要
    canvas.drawPath(combine, _paint);
複製代碼

6、裁切畫布並繪製頂層文本

文本的顏色是分爲上下兩部分的,foregroundColor 顏色的文本不須要顯示上半部分,因此在繪製 foregroundColor 文本的時候須要把上半部分文本給裁切掉,使兩次不一樣時間繪製的文本重疊在了一塊兒,獲得了有不一樣顏色範圍的文本

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

8、添加動畫

如今已經繪製好了單獨一幀時的效果圖了,能夠考慮使 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,
      ),
    );
  }
}
複製代碼

9、包裹爲 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

相關文章
相關標籤/搜索