本篇内容介绍了“怎么用Android贝塞尔曲线绘制一个波浪球”的有关知识,在实际案例的操作过程中,不少人都会遇到这样的困境,接下来就让小编带领大家学习一下如何处理这些情况吧!希望大家仔细阅读,能够学有所成!
效果如下所示:
先来总结下 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, )