Flutter 實戰 - 用貝塞爾曲線畫一個帶文字的波浪球 Widget
flutter 中的自定義 Widget 算作是 flutter 體系中比較高階的知識點之一了,相當於原生開發中的自定義 View,以我個人的感受來說,自定義 widget 的難度要低於自定義 View,不過由於當前 flutter 的開源庫還不算多豐富,所以有些效果還是需要開發者自己動手來實現,而本篇文章就來介紹如何用 flutter 來實現一個帶文字的波浪球 Widget,實現的的效果如下所示:
原始碼點選這裡下載:github.com/leavesC/flu…
先來總結下該 WaveLoadingWidget 的特點,這樣才能歸納出實現該效果所需的步驟
- widget 的主體是一個不規則的半圓,頂部以類似於波浪的形式從左往右上下波動執行
- 球形波浪可以自定義顏色,此處以 waveColor 命名
- 波浪的起伏線將嵌入的文字分為上下兩種顏色,上邊的文字顏色以 backgroundColor 命名,下邊的文字顏色以 foregroundColor 命名,文字的顏色一直在動態變化中
雖然波浪是不斷運動的,但只要能夠繪製出其中一幀的圖形,其動態效果就能通過不斷改變波浪的位置引數來完成,所以這裡先把該 widget 當成靜態的,先實現其靜態效果即可
將繪製步驟拆解為以下幾步:
- 繪製顏色為 backgroundColor 的文字,將其繪製在 canvas 的最底層
- 根據 widget 的寬高資訊構建一個不超出範圍的最大圓形路徑 circlePath
- 以 circlePath 的水平中間線作為波浪的起伏線,在起伏線的上邊和下邊分別利用貝塞爾曲線繪製一段連續的波浪 path,將 path 的首尾兩端以矩形的形式連線在一起,構成 wavePath,wavePath 的底部會與 circlePath 的底部相交於一點
- 取 circlePath 和 wavePath 的交集 targetPath,用 waveColor 填充, 此時就得到了半圓形的球形波浪了
- 利用
canvas.clipPath(targetPath)
方法裁切畫布,再繪製顏色為 foregroundColor 的文字,此時繪製的 foregroundColor 文字只會顯示 targetPath 範圍內的部分,從而使兩次不同時間繪製的文字重疊在了一起,得到了有不同顏色範圍的文字 - 利用 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));
}
複製程式碼
三、構建圓形路徑 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);
}
複製程式碼
此時繪製的曲線還處於非閉合狀態,需要將 _wavePath 的首尾兩端連線起來,這樣才可以和 _circlePath 做交集
_wavePath.relativeLineTo(0, radius);
_wavePath.lineTo(-waveWidth, side);
_wavePath.close();
複製程式碼
_wavePath 閉合後,此時繪製出來的圖形就如下所示
五、取 _circlePath 和 _wavePath 的交集
_circlePath 和 _wavePath 的交集就是一個半圓形波浪了
var combine = Path.combine(PathOperation.intersect, _circlePath, _wavePath);
canvas.drawPath(combine, _paint);
//為了方便讀者理解,這裡把路徑繪製出來,實際上不需要
canvas.drawPath(combine, _paint);
複製程式碼
六、裁切畫布並繪製頂層文字
文字的顏色是分為上下兩部分的,foregroundColor 顏色的文字不需要顯示上半部分,所以在繪製 foregroundColor 文字的時候需要把上半部分文字給裁切掉,使兩次不同時間繪製的文字重疊在了一起,得到了有不同顏色範圍的文字
canvas.clipPath(combine);
_drawText(canvas: canvas, side: side, colors: foregroundColor);
複製程式碼
八、新增動畫
現在已經繪製好了單獨一幀時的效果圖了,可以考慮使 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