Flutter:教你用CustomPaint画一个自定义的CircleProgressBar

本文详细介绍了如何使用Flutter编程语言创建一个动态的CircleProgressBar,包括绘制带渐变色的进度条、处理strokeCap属性、使用手势控制和动画,以及添加阴影效果。作者还分享了解决代码中遇到的问题和优化技巧。
摘要由CSDN通过智能技术生成

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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在给线绘制圆角时,是在线长的外面加了一段圆角,导致实际长度会超过我们定义的长度。那怎么办呢?还是曲线救国,我们在drawArc的时候,将起始角度往后偏移一段不就可以了吗?我们将这段偏移弧度定义为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;

@override
void 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);
}
progressController.value = radians / degToRad(360.0);
}
将触摸点所在的角度转化为进度,改变progressController.value的值,通过setState()的方式,通知界面刷新,一个跟随着用户手势而更改进度的CircleProgressBar就完成了。
一些其他的细节
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则近万的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

img

img

img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)

小结

有了这么多优秀的开发工具,可以做出更高质量的Android应用。

当然了,“打铁还需自身硬”,想要写出优秀的代码,最重要的一点还是自身的技术水平,不然用再好的工具也不能发挥出它的全部实力。

在这里我也分享一份大佬自己收录整理的Android学习PDF+架构视频+面试文档+源码笔记,还有高级架构技术进阶脑图、Android开发面试专题资料,高级进阶架构资料这些都是我闲暇还会反复翻阅的精品资料。在脑图中,每个知识点专题都配有相对应的实战项目,可以有效的帮助大家掌握知识点。

总之也是在这里帮助大家学习提升进阶,也节省大家在网上搜索资料的时间来学习,也可以分享给身边好友一起学习

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

*,还有高级架构技术进阶脑图、Android开发面试专题资料,高级进阶架构资料这些都是我闲暇还会反复翻阅的精品资料。在脑图中,每个知识点专题都配有相对应的实战项目,可以有效的帮助大家掌握知识点。

总之也是在这里帮助大家学习提升进阶,也节省大家在网上搜索资料的时间来学习,也可以分享给身边好友一起学习

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

  • 26
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值