Android 貝塞爾曲線繪制一個波浪球

前言

當 flutter 的現有組件無法滿足產品要求的 UI 效果時,我們就需要通過自繪組件的方式來進行實現瞭。本篇文章就來介紹如何用 flutter 自定義實現一個帶文本的波浪球,效果如下所示:

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

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

雖然文本的整體顏色是在不斷變化的,但隻要能夠繪制出其中一幀的圖形,其動態效果就能通過不斷改變波浪曲線的位置參數來實現,所以這裡先把該 widget 當成靜態的,先實現其靜態效果即可

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

  • 繪制顏色為 backgroundColor 的文本,將其繪制在 canvas 的最底層
  • 根據 widget 的寬高信息構建一個不超出范圍的最大圓形路徑 circlePath
  • 以 circlePath 的水平中間線作為波浪的基準起伏線,在起伏線的上邊和下邊分別用貝塞爾曲線繪制一段連續的波浪 path,將 path 的首尾兩端以矩形的方式連接在一起,構成 wavePath,wavePath 的底部會與 circlePath 的最底部相交
  • 取 circlePath 和 wavePath 的交集 combinePath,用 waveColor 填充, 此時就得到瞭半圓形的球形波浪瞭
  • 利用 canvas.clipPath(combinePath) 方法裁切畫佈,再繪制顏色為 foregroundColor 的文本,此時繪制的 foregroundColor 文本隻會顯示 combinePath 范圍內的部分,也即隻會顯示下半部分,使得兩次不同時間繪制的文本重疊在瞭一起,從而得到瞭有不同顏色范圍的文本
  • 利用 AnimationController 不斷改變 wavePath 的起始點的 X 坐標,同時重新刷新 UI,從而得到波浪不斷從左往右起伏運行的動態效果

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

一、繪制 backgroundColor 文本

flutter 通過 CustomPainter 為開發者提供瞭自繪 UI 的入口,其內部的 void paint(Canvas canvas, Size size) 方法提供瞭畫佈 canvas 對象以及包含 widget 寬高信息的 size 對象

這裡就來繼承 CustomPainter 類,在 paint 方法中先來繪制顏色為 backgroundColor 的文本。flutter 的 canvas 對象沒有提供直接 drawText 的 API,所以其繪制文本的步驟相對原生的自定義 View 要稍微麻煩一點

class _WaveLoadingPainter extends CustomPainter {
  final String text;

  final double fontSize;

  final double animatedValue;

  final Color backgroundColor;

  final Color foregroundColor;

  final Color waveColor;

  _WaveLoadingPainter({
    required this.text,
    required this.fontSize,
    required this.animatedValue,
    required this.backgroundColor,
    required this.foregroundColor,
    required this.waveColor,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final side = min(size.width, size.height);
    _drawText(canvas: canvas, side: side, color: backgroundColor);
  }

  void _drawText(
      {required Canvas canvas, required double side, required Color color}) {
    ParagraphBuilder paragraphBuilder = ParagraphBuilder(ParagraphStyle(
      textAlign: TextAlign.center,
      fontStyle: FontStyle.normal,
      fontSize: fontSize,
    ));
    paragraphBuilder.pushStyle(ui.TextStyle(color: color));
    paragraphBuilder.addText(text);
    ParagraphConstraints pc = ParagraphConstraints(width: fontSize);
    Paragraph paragraph = paragraphBuilder.build()..layout(pc);
    canvas.drawParagraph(
      paragraph,
      Offset((side - paragraph.width) / 2.0, (side - paragraph.height) / 2.0),
    );
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return animatedValue != (oldDelegate as _WaveLoadingPainter).animatedValue;
  }
}

二、構建 circlePath

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

  @override
  void paint(Canvas canvas, Size size) {
    final side = min(size.width, size.height);
    _drawText(canvas: canvas, side: side, color: backgroundColor);

    final circlePath = Path();
    circlePath.addArc(Rect.fromLTWH(0, 0, side, side), 0, 2 * pi);
  }

三、繪制波浪線

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

  @override
  void paint(Canvas canvas, Size size) {
    final side = min(size.width, size.height);
    _drawText(canvas: canvas, side: side, color: backgroundColor);

    final circlePath = Path();
    circlePath.addArc(Rect.fromLTWH(0, 0, side, side), 0, 2 * pi);

    final waveWidth = side * 0.8;
    final waveHeight = side / 6;
    final wavePath = Path();
    final radius = side / 2.0;
    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);
    }
    //為瞭方便讀者理解,這裡把 wavePath 繪制出來,實際上不需要
    final paint = Paint()
      ..isAntiAlias = true
      ..style = PaintingStyle.fill
      ..strokeWidth = 3
      ..color = waveColor;
    canvas.drawPath(wavePath, paint);
  }

此時繪制的曲線還處於非閉合狀態,需要將 wavePath 的首尾兩端連接起來,這樣後面才可以和 circlePath 取交集

wavePath.relativeLineTo(0, radius);
wavePath.lineTo(-waveWidth, side);
wavePath.close();
//為瞭方便讀者理解,這裡把 wavePath 繪制出來,實際上不需要
final paint = Paint()
  ..isAntiAlias = true
  ..style = PaintingStyle.fill
  ..strokeWidth = 3
  ..color = waveColor;
canvas.drawPath(wavePath, paint);

wavePath 閉合後,此時半圓的顏色就會鋪滿瞭

四、取交集

取 circlePath 和 wavePath 的交集,就得到一個半圓形波浪球瞭

final paint = Paint()
  ..isAntiAlias = true
  ..style = PaintingStyle.fill
  ..strokeWidth = 3
  ..color = waveColor;
final combinePath = Path.combine(PathOperation.intersect, circlePath, wavePath);
canvas.drawPath(combinePath, paint);

五、繪制 foregroundColor 文本

文本的顏色是分為上下兩部分的,上半部分顏色為 backgroundColor,下半部分為 foregroundColor。在第一步的時候已經繪制瞭顏色為 backgroundColor 的文本瞭,foregroundColor 文本不需要顯示上半部分,所以在繪制 foregroundColor 文本之前需要先把繪制區域限定在 combinePath 內,使得兩次不同時間繪制的文本重疊在瞭一起,從而得到有不同顏色范圍的文本

canvas.clipPath(combinePath);
_drawText(canvas: canvas, side: side, color: foregroundColor);

六、添加動畫

現在已經繪制好靜態時的效果瞭,可以考慮如何使 widget 動起來瞭

要實現動態效果也很簡單,隻要不斷改變貝塞爾曲線的起始點坐標,使之不斷從左往右移動,就可以營造出波浪從左往右前進的效果瞭。_WaveLoadingPainter 根據外部傳入的動畫值 animatedValue 來設置 wavePath 的起始坐標點即可,生成 animatedValue 的邏輯和其它繪制參數均由 _WaveLoadingState 來提供

class _WaveLoadingState extends State<WaveLoading>
    with SingleTickerProviderStateMixin {
  String get _text => widget.text;

  double get _fontSize => widget.fontSize;

  Color get _backgroundColor => widget.backgroundColor;

  Color get _foregroundColor => widget.foregroundColor;

  Color get _waveColor => widget.waveColor;

  late AnimationController _controller;

  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
        duration: const Duration(milliseconds: 700), vsync: this);
    _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 RepaintBoundary(
      child: CustomPaint(
        painter: _WaveLoadingPainter(
          text: _text,
          fontSize: _fontSize,
          animatedValue: _animation.value,
          backgroundColor: _backgroundColor,
          foregroundColor: _foregroundColor,
          waveColor: _waveColor,
        ),
      ),
    );
  }
}

_WaveLoadingPainter 根據 animatedValue 來設置 wavePath 的起始坐標點

wavePath.moveTo((animatedValue - 1) * waveWidth, radius);

七、使用

最後將 _WaveLoadingState 包裹到 StatefulWidget 中,在 StatefulWidget 中開放可以自定義配置的參數就可以瞭

class WaveLoading extends StatefulWidget {
  final String text;

  final double fontSize;

  final Color backgroundColor;

  final Color foregroundColor;

  final Color waveColor;

  WaveLoading({
    Key? key,
    required this.text,
    required this.fontSize,
    required this.backgroundColor,
    required this.foregroundColor,
    required this.waveColor,
  }) : super(key: key) {
    assert(text.isNotEmpty && fontSize > 0);
  }

  @override
  State<StatefulWidget> createState() {
    return _WaveLoadingState();
  }
}

使用方式:

SizedBox(
	width: 300,
	height: 300,
	child: WaveLoading(
  		text: "開",
  		fontSize: 210,
  		backgroundColor: Colors.lightBlue,
  		foregroundColor: Colors.white,
  		waveColor: Colors.lightBlue,
)

源代碼看這裡:WaveLoadingWidget

以上就是Android 貝塞爾曲線繪制一個波浪球的詳細內容,更多關於Android貝塞爾曲線的資料請關註WalkonNet其它相關文章!

推薦閱讀: