
28350_1669639768.gif
創(chuàng)建canvas畫板:
return CustomPaint(
size: Size(100, 100),
painter: WavePainter(
progress: 0.5,
waveColor: Colors.blue,
),
)
創(chuàng)建一個(gè)WavePainter繼承CustomPainter:
class WavePainter extends CustomPainter {
final double progress;
final Color waveColor;
WavePainter({
required this.progress,
required this.waveColor,
});
final Paint wavePaint = Paint();
double painterHeight = 0; // 波浪總體高度
double waveWidth = 0; // 波浪寬度
double waveHeight = 0; // 波浪浪尖高度
@override
void paint(Canvas canvas, Size size) {
painterHeight = size.height;
waveWidth = size.width / 2;
waveHeight = size.height * 0.06;
// 繪制波浪
drawWave(
canvas,
Offset(-4 * waveWidth,
painterHeight + waveHeight),
waveColor,
);
}
Path drawWave(Canvas canvas, Offset startPoint, Color color) {
Path wavePath = Path();
wavePath.moveTo(startPoint.dx, startPoint.dy);
wavePath.relativeLineTo(0, -painterHeight * progress);
int waveCount = 3;
for (int i = 0; i < waveCount; i++) {
wavePath.relativeQuadraticBezierTo(
waveWidth / 2, -waveHeight * 2, waveWidth, 0);
wavePath.relativeQuadraticBezierTo(
waveWidth / 2, waveHeight * 2, waveWidth, 0);
}
wavePath.relativeLineTo(0, painterHeight);
wavePath.relativeLineTo(-waveWidth * waveCount * 2.0, 0);
canvas.drawPath(wavePath, wavePaint..color = color);
return wavePath;
}
@override
bool shouldRepaint(WavePainter oldDelegate) {
return false;
}
}
繪制波浪的方法寫在drawWave中,在CustomPaint外面套一個(gè)Container看下效果先:

位圖1.png
Container的clipBehavior屬性去掉可以砍出波浪的具體位置,現(xiàn)在波浪是靜止的
下面波浪動(dòng)起來,給WavePainter傳入一個(gè)Animation<double>動(dòng)畫:
AnimationController _waveCtrl;
_waveCtrl = AnimationController(
duration: Duration(seconds: 1),
vsync: this,
)..repeat();
WavePainter(
waveColor: widget.waveColor,
progress: progress,
flow: _waveCtrl,
)
class WavePainter extends CustomPainter {
final double progress;
final Color waveColor;
final Animation<double> flow;
WavePainter({
required this.progress,
required this.waveColor,
required this.flow,
}) : super(repaint: flow);
// 省略重復(fù)代碼...
@override
void paint(Canvas canvas, Size size) {
// 省略重復(fù)代碼...
// 繪制波浪
drawWave(
canvas,
Offset(-4 * waveWidth + 2 * waveWidth * flow.value,
painterHeight + waveHeight),
waveColor,
);
}
Path drawWave(Canvas canvas, Offset startPoint, Color color) {
// 省略重復(fù)代碼...
}
@override
bool shouldRepaint(WavePainter oldDelegate) {
return oldDelegate.flow != flow;
}
}

219_1669687025.gif
再來一道底波:
@override
void paint(Canvas canvas, Size size) {
// 省略重復(fù)代碼...
// 繪制波浪
drawWave(
canvas,
Offset(-4 * waveWidth + 2 * waveWidth * flow.value,
painterHeight + waveHeight),
waveColor,
);
// 繪制底波
drawWave(
canvas,
Offset(-4 * waveWidth + 4 * waveWidth * flow.value,
painterHeight + waveHeight),
waveColor.withAlpha(80),
);
}
底波橫向移動(dòng)速度翻倍4 * waveWidth * flow.value,透明度80%
外層Container的clipBehavior屬性改為Clip.antiAlias,再動(dòng)態(tài)更新progress的值:

220.gif
接下來加上文字:
@override
void paint(Canvas canvas, Size size) {
// 省略重復(fù)代碼...
// 繪制文字
drawText(canvas, size, textColor);
// 繪制波浪
drawWave(
canvas,
Offset(-4 * waveWidth + 2 * waveWidth * flow.value,
painterHeight + waveHeight),
waveColor,
);
// 繪制底波
drawWave(
canvas,
Offset(-4 * waveWidth + 4 * waveWidth * flow.value,
painterHeight + waveHeight),
waveColor.withAlpha(80),
);
}
void drawText(Canvas canvas, Size size, Color color) {
// 文字內(nèi)容
String text = '加載中...';
// 文字樣式
TextStyle textStyle = TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: color,
);
// 最大行數(shù)
int maxLines = 2;
// 文字畫筆
_textPainter
..text = TextSpan(
text: text,
style: textStyle,
)
..maxLines = maxLines
..textDirection = TextDirection.ltr;
// 繪制文字
_textPainter.layout(maxWidth: size.width);
// 文字Size
Size textSize = _textPainter.size;
_textPainter.paint(
canvas,
Offset(
(size.width - textSize.width) / 2,
size.height / 2 + (size.height / 2 - textSize.height) / 2,
),
);
}
文字繪制完發(fā)現(xiàn)和波浪混一起就看不到了

位圖.png
中間嘗試過用blendMode和colorFilter讓文字和波浪重疊的部分混色,效果不太理想
采取另一種辦法,繪制兩遍文字,波浪上方繪制一遍,波浪下方繪制一遍:
@override
void paint(Canvas canvas, Size size) {
// 省略重復(fù)代碼...
// 繪制波浪上方文字
drawText(canvas, size, textColor);
// 繪制波浪
Path wavePath = drawWave(
canvas,
Offset(-4 * waveWidth + 2 * waveWidth * flow.value,
painterHeight + waveHeight),
waveColor,
);
// 繪制底波
drawWave(
canvas,
Offset(-4 * waveWidth + 4 * waveWidth * flow.value,
painterHeight + waveHeight),
waveColor.withAlpha(80),
);
// 繪制波浪下方文字
canvas.clipPath(wavePath);
drawText(canvas, size, Colors.white);
}
繪制波浪下方文字時(shí)用clipPath沿著波浪wavePath裁剪了一下,這樣文字就只在波浪上顯示了,不會(huì)超出波浪范圍:

位圖.png
完整代碼,對(duì)波浪組件進(jìn)行了封裝:
import 'dart:async';
import 'package:flutter/material.dart';
class WaveLoading extends StatefulWidget {
// 進(jìn)度
final double progress;
// 波浪顏色
final Color waveColor;
// 尺寸
final Size size;
// 圓角半徑
final double borderRadius;
// 動(dòng)畫時(shí)長
final Duration duration;
// 文字
final String text;
// 字號(hào)
final double fontSize;
// 文字顏色
final Color? textColor;
// 是否需要省略號(hào)
final bool needEllipsis;
const WaveLoading({
Key? key,
this.progress = 0.6,
this.waveColor = Colors.blue,
this.size = const Size(100, 100),
this.borderRadius = 0,
this.duration = const Duration(seconds: 1),
this.text = '加載中',
this.fontSize = 15,
this.textColor,
this.needEllipsis = true,
}) : super(key: key);
@override
State<WaveLoading> createState() => _WaveLoadingState();
}
class _WaveLoadingState extends State<WaveLoading>
with TickerProviderStateMixin {
late AnimationController _waveCtrl;
Timer? _timer;
ValueNotifier<int> _ellipsisCount = ValueNotifier(1); // 文字省略號(hào)點(diǎn)的個(gè)數(shù)
@override
void initState() {
super.initState();
// 初始化動(dòng)畫控制器
_initAnimationCtrl();
}
@override
void dispose() {
_waveCtrl.dispose();
_timer?.cancel();
super.dispose();
}
// 初始化動(dòng)畫控制器
void _initAnimationCtrl() {
_waveCtrl = AnimationController(
duration: widget.duration,
vsync: this,
)..repeat();
if (widget.needEllipsis) {
// 有省略號(hào)才初始化計(jì)時(shí)器
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_ellipsisCount.value < 3) {
_ellipsisCount.value++;
} else {
_ellipsisCount.value = 1;
}
});
}
}
@override
Widget build(BuildContext context) {
double progress = widget.progress > 1 ? 1 : widget.progress;
return RepaintBoundary(
child: CustomPaint(
size: widget.size,
painter: WavePainter(
waveColor: widget.waveColor,
borderRadius: widget.borderRadius,
progress: progress,
repaint: Listenable.merge([_waveCtrl, _ellipsisCount]),
flow: _waveCtrl,
ellipsisCount: _ellipsisCount,
text: widget.text,
textColor: widget.textColor ?? widget.waveColor,
fontSize: widget.fontSize,
needEllipsis: widget.needEllipsis,
),
),
);
}
}
class WavePainter extends CustomPainter {
final Listenable repaint;
final Animation<double> flow;
final ValueNotifier<int> ellipsisCount;
final double progress;
final Color waveColor;
final double borderRadius;
final String text;
final double fontSize;
final Color textColor;
final bool needEllipsis;
WavePainter({
required this.repaint,
required this.flow,
required this.ellipsisCount,
required this.progress,
required this.waveColor,
required this.borderRadius,
required this.text,
required this.fontSize,
required this.textColor,
required this.needEllipsis,
}) : super(repaint: repaint);
final Paint wavePaint = Paint();
final Paint borderPaint = Paint();
final TextPainter _textPainter = TextPainter();
double painterHeight = 0;
double waveWidth = 0;
double waveHeight = 0;
@override
void paint(Canvas canvas, Size size) {
painterHeight = size.height;
waveWidth = size.width / 2;
waveHeight = size.height * 0.06;
borderPaint
..style = PaintingStyle.fill
..color = waveColor.withAlpha(15);
// 繪制背景
Path borderPath = Path();
borderPath.addRRect(
RRect.fromRectXY(Offset.zero & size, borderRadius, borderRadius));
canvas.clipPath(borderPath);
canvas.drawPath(borderPath, borderPaint);
// 繪制波浪上方文字
drawText(canvas, size, textColor);
// 繪制波浪
Path wavePath = drawWave(
canvas,
Offset(-4 * waveWidth + 2 * waveWidth * flow.value,
painterHeight + waveHeight),
waveColor,
);
// 繪制底波
drawWave(
canvas,
Offset(-4 * waveWidth + 4 * waveWidth * flow.value,
painterHeight + waveHeight),
waveColor.withAlpha(80),
);
// 繪制波浪下方文字
canvas.clipPath(wavePath);
drawText(canvas, size, Colors.white);
}
Path drawWave(Canvas canvas, Offset startPoint, Color color) {
Path wavePath = Path();
wavePath.moveTo(startPoint.dx, startPoint.dy);
wavePath.relativeLineTo(0, -painterHeight * progress);
int waveCount = 3;
for (int i = 0; i < waveCount; i++) {
wavePath.relativeQuadraticBezierTo(
waveWidth / 2, -waveHeight * 2, waveWidth, 0);
wavePath.relativeQuadraticBezierTo(
waveWidth / 2, waveHeight * 2, waveWidth, 0);
}
wavePath.relativeLineTo(0, painterHeight);
wavePath.relativeLineTo(-waveWidth * waveCount * 2.0, 0);
canvas.drawPath(wavePath, wavePaint..color = color);
return wavePath;
}
void drawText(Canvas canvas, Size size, Color color) {
// 文字內(nèi)容
String content = text;
if (needEllipsis) {
String ellipsis = '.' * ellipsisCount.value;
content += ellipsis.toString();
}
// 文字樣式
TextStyle textStyle = TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
color: color,
);
// 最大行數(shù)
int maxLines = 2;
// 文字畫筆
_textPainter
..text = TextSpan(
text: content,
style: textStyle,
)
..maxLines = maxLines
..textDirection = TextDirection.ltr;
// 文字Size,如果repaint不為空,說明需要省略號(hào),計(jì)算時(shí)拼接上三個(gè)點(diǎn),得出最大寬度
Size textSize = sizeWithLabel(
needEllipsis ? text + '...' : text,
textStyle,
maxLines,
);
// 繪制文字
_textPainter.layout(maxWidth: size.width);
_textPainter.paint(
canvas,
Offset(
(size.width - textSize.width) / 2,
size.height / 2 + (size.height / 2 - textSize.height) / 2,
),
);
}
// 計(jì)算文字Size
Size sizeWithLabel(String text, TextStyle textStyle, int maxLines) {
TextSpan textSpan = TextSpan(text: text, style: textStyle);
TextPainter textPainter = TextPainter(
text: textSpan, maxLines: maxLines, textDirection: TextDirection.ltr);
textPainter.layout();
return textPainter.size;
}
@override
bool shouldRepaint(WavePainter oldDelegate) {
return oldDelegate.repaint != repaint ||
oldDelegate.flow != flow ||
oldDelegate.progress != progress ||
oldDelegate.waveColor != waveColor ||
oldDelegate.borderRadius != borderRadius ||
oldDelegate.text != text ||
oldDelegate.textColor != textColor ||
oldDelegate.fontSize != fontSize ||
oldDelegate.needEllipsis != needEllipsis ||
oldDelegate.ellipsisCount != ellipsisCount;
}
}
使用:
TextButton(
child: Text(
'show',
style: TextStyle(
fontSize: 30,
),
),
onPressed: () {
showDialog(
context: context,
barrierDismissible: true,
barrierColor: Colors.transparent,
builder: (context) {
return Center(
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10.w),
// 陰影
boxShadow: const [
BoxShadow(
color: Colors.grey,
offset: Offset(0, 1), // 陰影xy軸偏移量
blurRadius: 0.1, // 陰影模糊程度
spreadRadius: 0.1, // 陰影擴(kuò)散程度
),
],
),
clipBehavior: Clip.antiAlias,
child: Obx(() {
// 這里為了實(shí)時(shí)刷新,使用了Getx狀態(tài)管理框架,換成其他方式亦可
return WaveLoading(
size: Size(200.w, 200.w),
progress: progress.value / 100,
text: '${progress.value}%',
fontSize: 36.sp,
needEllipsis: false,
);
}),
),
);
},
).then((value) {
progress.value = 0;
timer?.cancel();
});
// 模擬progress更新
timer = Timer.periodic(Duration(milliseconds: 100), (timer) {
if (progress.value < 100) {
progress.value++;
}
// debugPrint(progress.value.toString());
});
},
)