写点什么

Flutter:教你用 CustomPaint 画一个自定义的 CircleProgressBar,移动混合开发技术

作者:嘟嘟侠客
  • 2021 年 11 月 27 日
  • 本文字数:4653 字

    阅读完需:约 15 分钟

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),上述代码可以绘制出一个基础的圆弧。但是我们会发现,圆弧的两端是平的,很影响美观,这时候就需要用到paintstrokeCap属性了。



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



接下来我们给进度条添加颜色,按照设计稿,我们需要添加一个渐变色。渐变色可以通过paintshader属性来实现:


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);

最后

下面是辛苦给大家整理的学习路线



本文已被CODING开源项目:《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》收录

用户头像

嘟嘟侠客

关注

还未添加个人签名 2021.03.19 加入

还未添加个人简介

评论

发布
暂无评论
Flutter:教你用CustomPaint画一个自定义的CircleProgressBar,移动混合开发技术