Flutter:教你用 CustomPaint 画一个自定义的 CircleProgressBar,移动混合开发技术
final Offset offsetCenter = Offset(center, center);final ringPaint = Paint()..style = PaintingStyle.stroke..color = ringColor..strokeWidth = (outerRadius - innerRadius);canvas.drawCircle(offsetCenter, drawRadius, ringPaint);
canvas.drawCircle(Offset c, double radius, Paint paint)
这个方法就是绘制一个圆,其中 c 为圆心坐标点,这个 offset 偏移值是以画布原点(左上角)为坐标轴中心点来计算的,很明显大小为offsetCenter = Offset(center, center)
;radius 为圆环半径,大小其实就是图上标示的drawRadius
;paint 就是我们的画笔,这里要注意,绘制圆环需要设置style = PaintingStyle.stroke
,否则画笔会默认充满内部,那么你绘制出来的就是一个圆了。

Step 2 底部进度条
绘制进度条实际上就是绘制圆弧,我们使用canvas.drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint)
。 rect 参数就是圆弧所在的整圆的 Rect,我们使用Rect.fromCircle
来构造这个整圆的 Rect:final Rect arcRect = Rect.fromCircle(center: offsetCenter, radius: drawRadius);
;startAngle
为起始弧度,sweepAngle
为需要绘制的圆弧长度,这里要注意,这两个值都是 弧度制 的,canvas 里面与角度有关的变量都是弧度制的,在计算的时候一定要注意;useCenter
属性标示是否需要将圆弧与圆心相连;paint
就是我们的画笔。
补充:弧度与角度的弧线转换:
num degToRad(num deg) => deg * (pi / 180.0);num radToDeg(num rad) => rad * (180.0 / pi);

final angle = 360.0 * progress;final double radians = degToRad(angle);final Rect arcRect = Rect.fromCircle(center: offsetCenter, radius: drawRadius);final progressPaint = Paint()..style = PaintingStyle.stroke..strokeWidth = progressWidth;canvas.drawArc(arcRect, 0.0, degToRad(angle), false, progressPaint);
假设当前进度为progress
(范围为 0.0~1.0),那么当前角度为angle = 360.0 * progress
,当前弧度为radians = degToRad(angle)
,上述代码可以绘制出一个基础的圆弧。但是我们会发现,圆弧的两端是平的,很影响美观,这时候就需要用到paint
的strokeCap
属性了。

我们将paint
设置为StrokeCap.round
,就能得到一个最基本的进度条了。

接下来我们给进度条添加颜色,按照设计稿,我们需要添加一个渐变色。渐变色可以通过paint
的shader
属性来实现:
final Gradient gradient = new SweepGradient(endAngle: radians,colors: [Colors.white,currentDotColor,],);final progressPaint = Paint()..style = PaintingStyle.stroke..strokeCap = StrokeCap.round..strokeWidth = progressWidth..shader = gradient.createShader(arcRect);
Flutter 提供了三种基础的用来绘制渐变效果的类:SweepGradient(扫描渐变)、LinearGradient(线性渐变)和 RadialGradient(径向渐变)。

很明显,我们需要用到的是SweepGradient
:
final Gradient gradient = new SweepGradient(endAngle: radians,colors: [Colors.white,currentDotColor,],);
注意,这里有一个很大的坑,我们可以从上面的 SweepGradient 事例图上看到,默认情况下是从 90°的地方作为起点的,这跟我们的要求明显是不符的。SweepGradient 有一个 startAngle 属性,那么我们是否可以将其设置为degToRad(-90°)
就可以解决问题了呢?答案是:不可以。这里怀疑是 Flutter 的一个 bug,startAngle 属性不生效,我们可以看一下这个 issue:SweepGradient startAngle doesn't work as expected.

那么怎么解决呢?我想了很久之后决定采用一个曲线救国的方法,那就是:旋转画布!!。反正是一个圆弧嘛,那我把画布逆时针旋转 90°不就行了嘛(这里还要注意,画布默认旋转中心为坐标轴原点,而且貌似不能更改,至少我没找到,所以需要旋转后再平移,对 canvas 的位置操作需要倒着写,所以实际代码是先写 translate,再写 rotate):
canvas.save();canvas.translate(0.0, size.width);canvas.rotate(degToRad(-90.0));······canvas.drawArc(arcRect, 0.0, degToRad(angle), false, paint);canvas.restore();
画到这里你是不是觉得已经很 OK 了呢?运行一下,啊嘞,怎么会这样纸?

这是我们给 stroke 设置了 StrokeCap.round 导致的,因为 Flutter 在给线绘制圆角时,是在线长的外面加了一段圆角,导致实际长度会超过我们定义的长度。那怎么办呢?还是曲线救国,我们在 drawA
《Android 学习笔记总结+最新移动架构视频+大厂安卓面试真题+项目实战源码讲义》
【docs.qq.com/doc/DSkNLaERkbnFoS0ZF】 完整内容开源分享
rc 的时候,将起始角度往后偏移一段不就可以了吗?我们将这段偏移弧度定义为offset
,其大小为offset = asin(progressWidth * 0.5 / drawRadius)
(怎么算出来的?数学问题,自己那张草稿纸画画就知道啦~)。
所以最终的绘制代码应该为:
canvas.drawArc(arcRect, offset, degToRad(angle) - offset, false, progressPaint);
那么到此为止,我们的进度条部分也绘制完成了。
Step 3 绘制小圆点
绘制小圆点就比较简单了,只要计算出小圆点的圆心位置就可以了,纯初中数学计算,自己拿纸画画就知道啦。绘制函数依然是canvas.drawCircle
,因为是绘制圆,所以不需要更改 PaintingStyle。
final double dx = center + drawRadius * sin(radians);final double dy = center - drawRadius * cos(radians);final dotPaint = Paint()..color = currentDotColor;canvas.drawCircle(new Offset(dx, dy), dotRadius, dotPaint);dotPaint..color = dotEdgeColor..style = PaintingStyle.stroke..strokeWidth = dotRadius * 0.3;canvas.drawCircle(new Offset(dx, dy), dotRadius, dotPaint);
Step 4 细节修饰:绘制底部圆环阴影和小圆点外圈
绘制圆环阴影
绘制阴影有两种方法,实现出来的效果也不太一样。
1)使用canvas.drawShadow()
来绘制:
drawShadow(Path path, Color color, double elevation, bool transparentOccluder)
,根据 API 要求,我们需要先计算出圆环的 Path,Path 的相关 API 只支持向 path 中添加圆、弧线、直线、点等属性,我们没法直接构建一个圆环对应的对象 Path。换个角度思考一下,圆环的 Path 其实是外层圆与内层圆组合的结果,所以我们使用Path.combine()
方法来获得圆环的路径,通过设置组合模式为PathOperation.difference
可以获取内外两个圆的公共部分的 Path,也就是圆环的 Path:
Path path = Path.combine(PathOperation.difference,Path()..addOval(Rect.fromCircle(center: offsetCenter, radius: outerRadius)),Path()..addOval(Rect.fromCircle(center: offsetCenter, radius: innerRadius)));canvas.drawShadow(path, shadowColor, 4.0, true);
2)使用 paint 的MaskFilter.blur()
来绘制:
这个方法其实是用来绘制毛玻璃效果的,用来绘制阴影,听起来也有些曲线救国的意味,但是官方注释中有一句话:
Creates a mask filter that takes the shape being drawn and blurs it.
This is commonly used to approximate shadows.
所以这个真的也是可以用来绘制阴影的,而且 Flutter 在绘制一些 Button 控件的时候也是使用来 blur 的效果来实现的。MaskFilter.blur()
其实就是将你绘制的东西变模糊,所以我们可以绘制一个圆环,然后将其进行高斯模糊,造成一种加了“阴影”的假象。
final shadowPaint = Paint()..style = PaintingStyle.stroke..color = shadowColor..strokeWidth = shadowWidth..maskFilter = MaskFilter.blur(BlurStyle.normal, shadowWidth);canvas.drawCircle(offsetCenter, outerRadius, shadowPaint);canvas.drawCircle(offsetCenter, innerRadius, shadowPaint);

两者绘制结果的区别很明显,canvas.drawShadow()
是将整个圆环作为一个整体,为其添加阴影;而MaskFilter.blur()
其实就是绘制两个模糊的圆环,作为一种阴影的替代品。使用哪种方式绘制,还是取决于你需要什么样的效果。
小圆点外圈绘制
这个没什么难度的,就是在小圆点外面再绘制一个圆环而已:
dotPaint..color = dotEdgeColor..style = PaintingStyle.stroke..strokeWidth = dotRadius * 0.3;canvas.drawCircle(new Offset(dx, dy), dotRadius, dotPaint);
到此为止,一个静态的 CircleProgressBar 就绘制完成了:

添加手势控制
手势控制我们通过最简单的方式来实现,那就是在 CircleProgressBar 外面包裹一层GestureDetector
,然后在onPanUpdate
回调中刷新进度:
GestureDetector(onPanStart: _onPanStart,onPanUpdate: _onPanUpdate,onPanEnd: _onPanEnd,child: Container(alignment: FractionalOffset.center,child: CustomPaint(key: paintKey,size: size,painter: ProgressPainter(),),),)
进度的记录我们依然是使用AnimationController
,因为我们可以使用controller.animateTo()
方法,很方便得将进度条从当前位置平滑地移动到目标位置:
AnimationController progressController;
@overridevoid initState() {super.initState();progressController =AnimationController(duration: Duration(milliseconds: 300), vsync: this);if (widget.progress != null) progressController.value = widget.progress;progressController.addListener(() {if (widget.progressChanged != null)widget.progressChanged(progressController.value);setState(() {});});}
接下来就是判断用户的触摸点是否在有效范围内,因为用户只有在触摸圆环的时候才应该触发手势,判断方法也很简单,那就是看系统反馈给我们的 pointer 位置收否位于圆环上。但是实际操作会有一个问题,那就是系统反馈的触摸点位置是一个全局的坐标点,坐标轴原点在屏幕的左上角,然后圆环在屏幕中的全局坐标我们无法知晓。好在 Flutter 为我们提供了一个全局坐标与局部坐标的转换方法:
void _onPanUpdate(DragUpdateDetails details) {RenderBox getBox = key.currentContext.findRenderObject();Offset local = getBox.globalToLocal(details.globalPosition);}
拿到局部坐标后,通过计算触摸点与圆心的距离,是否在内、外半径范围内,就可以判断是否为有效触摸了(一般情况下触摸范围会比圆环更大一线,方便用户操作,所以我将 validInnerRadius 的值,设置地比 widget.radius - widget.dotRadius 更小一点):
bool _checkValidTouch(Offset pointer) {final double validInnerRadius = widget.radius - widget.dotRadius * 3;final double dx = pointer.dx;final double dy = pointer.dy;final double distanceToCenter =sqrt(pow(dx - widget.radius, 2) + pow(dy - widget.radius, 2));if (distanceToCenter < validInnerRadius ||distanceToCenter > widget.radius) {return false;}return true;}
接下来就是计算触摸点所在的角度了,要注意根据边来计算角度时,位于不同的象限,要做不同的处理:

void _onPanUpdate(DragUpdateDetails details) {if (!isValidTouch) {return;}RenderBox getBox = paintKey.currentContext.findRenderObject();Offset local = getBox.globalToLocal(details.globalPosition);final double x = local.dx;final double y = local.dy;final double center = widget.radius;double radians = atan((x - center) / (center - y));if (y > center) {radians = radians + degToRad(180.0);} else if (x < center) {radians = radians + degToRad(360.0);
最后
下面是辛苦给大家整理的学习路线

评论