注意:这其实是一篇CustomPaint的使用教程!!

源码地址:https://github.com/yumi0629/FlutterUI/tree/master/lib/circleprogressbar

  在Flutter中,CustomPaint就像是Android中的Paint一样,可以用它绘制出各种各样的自定义图形。确实,Paint的使用比较复杂,我觉得直接讲API的话也太无聊了,要记住Paint的用法,还是自己动手画一个比较实在。
  那为什么是画一个CircleProgressBar呢?其实这个控件本来是为了交作业的,之前在讲Hero的时候留了一个小练习,里面有一个页面,有一个很炫酷的圆形ProgressBar选择器,当时为了偷懒我就没写(不要打我),所以现在来补交来。在写这个CircleProgressBar的时候发现,CustomPaint中基本的API都使用到了,画圆、画弧线、画布旋转、Paint的各种属性的意义等等知识点都有涉及到。所以说,看完这篇文章,你绝对可以自己动手尝试画一些炫酷的UI控件来!
  国际惯例,先上效果图:

什么是CustomPaint

const CustomPaint({Key key,this.painter,this.foregroundPainter,this.size = Size.zero,this.isComplex = false,this.willChange = false,Widget child,})

  CustomPaint是一个继承自SingleChildRenderObjectWidget的控件,所以注意,不能用setState的方式来刷新它!!painter就是我们的主绘制工具,它是一个CustomPainterforegroundPainter是用来绘制前景的工具;size为画布大小,这个size会传递给PainterisComplexwillChange 是告诉Flutter你的CustomPaint是否复杂到需要使用cache相关的功能;child属性我们一般不填,即使你是想要在你的CustomPaint上添加一些其他的布局,也不建议放在child属中性,因为你会发现你并不会得到你想要的结果。
  所有的绘制都是发生在Painter里面的,绘制的代码写在我们的自定义CustomPainter中:

class ProgressPainter extends CustomPainter {@overridevoid paint(Canvas canvas, Size size) {// 绘制代码}@overridebool shouldRepaint(CustomPainter oldDelegate) {return true;}
}

  我们需要重写paint()shouldRepaint()这两个方法,一个是绘制流程,一个是在刷新布局的时候告诉Flutter是否需要重绘。注意下paint方法中的size参数,就是我们在CustomPaint中定义的size属性,它包含了基本的画布大小信息。
  真正地绘制则是通过canvasPaint来实现的,我们将定义好了的Paint画笔传递给canvas.drawXXX()方法,这个方法会告诉Flutter我们需要绘制一个什么东西,是一个圆呢、还是一条线呢?
  一些常用的canvas绘制API:

// 绘制弧线
drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint)
// 绘制图片
drawImage(Image image, Offset p, Paint paint)
// 绘制圆
drawCircle(Offset c, double radius, Paint paint)
// 绘制线条
drawLine(Offset p1, Offset p2, Paint paint)
// 绘制椭圆
drawOval(Rect rect, Paint paint)
// 绘制文字
drawParagraph(Paragraph paragraph, Offset offset)
// 绘制路径
drawPath(Path path, Paint paint)
// 绘制点
drawPoints(PointMode pointMode, List<Offset> points, Paint paint)
// 绘制Rect
drawRect(Rect rect, Paint paint)
// 绘制阴影
drawShadow(Path path, Color color, double elevation, bool transparentOccluder)

  一些常用的Paint属性:

color:画笔颜色
style:绘制模式,画线 or 充满
maskFilter:绘制完成,还没有被混合到布局上时,添加的遮罩效果,比如blur效果
strokeWidth:线条宽度
strokeCap:线条结束时的绘制样式
shader:着色器,一般用来绘制渐变效果或ImageShader

绘制步骤分析

  首先是静态进度条的绘制,我们先拆解这个CircleProgressBar为三部分:底部圆环、进度条和显示当前进度的小圆点。因为Canvas的绘制顺序是按代码顺序一层一层往上叠加的,所以我们的绘制步骤应该是:绘制底部圆环——>绘制进度条——>绘制小圆点。
  然后是手势拖动的实现,我们选用GestureDetector来实现就可以了,在onPanUpdate回调中实时刷新进度条与小圆点的位置,这里面需要注意的地方是可触摸区域的计算。

静态CircleProgressBar绘制

  绘制所需要的变量基本都标注在上图中了,圆心坐标就是整块画布的中心点,我们定义为(center,center),其中center = size.width * 0.5。小圆点的半径定义为dotRadius。灰色实线部分为底部圆环,progressBar的宽度为红色虚线部分所示,其大小应该比底部圆环略大,至于大多少,你可以自己定义。在本次的例子中,我将灰色实线与红色虚线之间的部定义为radiusOffset = dotRadius * 0.4,这个值尽量不要写死,那么radiusOffset*2就是progressBar宽度比底部圆环大的值。innerRadiusoutRadius分别为底部圆环的内/外半径,大小如图上所示(纯数学知识,不解释)。然后我们可以根据innerRadiusoutRadius计算出progressBar宽度progressWith = outerRadius - innerRadius + radiusOffsetdrawRadius是一个大小为画布宽度的一半减去小圆点半径的变量,这个变量在绘制progressBar和小圆点的时候很有用,用来确定progressBar和小圆点的位置。

Step 1 底部圆环绘制

  底部圆环的绘制非常简单,实际上就是画一个圆。为什么说画圆环和画圆会是一样的呢?Paint是画笔,回想一下我们在写字的时候,写出来的字是不是有粗有细?同样地,Paint在画线的时候也是有宽度的,我们画一个有宽度的圆,不就是画一个圆环了吗?

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在给线绘制圆角时,是在线长的外面加了一段圆角,导致实际长度会超过我们定义的长度。那怎么办呢?还是曲线救国,我们在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);

image

  两者绘制结果的区别很明显,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);}progressController.value = radians / degToRad(360.0);}

   将触摸点所在的角度转化为进度,改变progressController.value的值,通过setState()的方式,通知界面刷新,一个跟随着用户手势而更改进度的CircleProgressBar就完成了。

一些其他的细节

   在实际运行时,如果角度过小时,会出现下面的情况:

   这是因为我们在绘制进度条的时候进行了偏移导致的,如果你想通过调整进度条的方式来修改,会比较麻烦,不妨换个角度,当角度很小的时候(radians < offset),进度条其实是被小圆点挡住了,看不到的,那么直接不绘制就可以了。

  进度的监听可以通过暴露的回调progressChanged(double value)得到,范围是(0.0~1.0)

Flutter:教你用CustomPaint画一个自定义的CircleProgressBar相关推荐

  1. 教你用CSS3画一个透视、旋转的正方体

    教你用CSS3画一个透视.旋转的正方体 正方体六个面,无非就是,将每个面进行translateZ提升/降低边长的一半,然后再进行rotate X/rotate Y (n*90deg)的旋转即可. ht ...

  2. 圣诞节快到了,教大家用Python画一个简单的圣诞树和烟花,送给那个她

    用Python画出圣诞树和烟花,瞧瞧我这简易版的吧 序言 圣诞树 效果展示 代码展示 圣诞树上加烟花 效果展示 代码展示 写在最后 序言 这不是圣诞节快到了,准备让让女朋友开心开心,也算是亲手做的,稍 ...

  3. 教你用python画一个可爱皮卡丘!

    听说CSDN里的人都是绘图的好手,那么有种接受挑战嘛,和我一起绘制全网各态皮卡丘! 最近爬虫遇到瓶颈,于是找了几张图皮卡丘的图绘制一下,勉勉强强,还看得的下去,所以滋生了一个绘图比赛,本篇博客讲诉大致 ...

  4. 用C语言图形库画一个红色爱心

    这次我教大家用代码画一个心,这样你们就可以送给你们的女(男)朋友了.没找到对象的也可以用来表白啊. 1.首先,我去百度找了心形线的函数,如下: 2. 联系高中的数学知识,我们知道:f(x)>0和 ...

  5. 用ArkTs在鸿蒙系统上画一个世界杯海报

    偶然看到了CSDN关于世界杯的征文活动: 用代码画一个足球? 哈哈很有意思! 想了想,画一个自定义View(足球),当然是使用Canvas了,但除了Canvas还有没有其它方法呢?那是必须的了,那就是 ...

  6. Android之自定义View以及画一个时钟

    https://www.2cto.com/kf/201509/443112.html 概述: 当Android自带的View满足不了开发者时,自定义View就发挥了很好的作用. 建立一个自定义View ...

  7. 手把手带你画一个漂亮蜂窝view Android自定义view

    上一篇做了一个水波纹view  不知道大家有没有动手试试呢点击打开链接 这个效果做起来好像没什么意义,如果不加监听回调 图片就能直接替代.写这篇博客的目的是锻炼一下思维能力,以更好的面多各种自定义vi ...

  8. Flutter使用ClipPath画一个聊天气泡

    先看一下效果图: 原理: 先画一个三角形再画一个圆角矩形,然后把两个图案重合起来就实现 了一个聊天气泡 实现代码bubble.dart import 'package:flutter/material ...

  9. 自定义view(画一个LOL能力图表)

    在介绍下列的自定义图表的时候,我们先说明一下绘图的三大要素,canvas 翻译叫画布,paint叫画笔,Path对象叫路径, 一.canvas属性: ①:canvas画布本身是有一个坐标系的,它决定了 ...

最新文章

  1. 易飞90设计自己定义画面新增功能说明
  2. 【剑指offer-Java版】38数字在排序数组中出现的次数
  3. python类型转换-马哥教育官网-专业Linux培训班,Python培训机构
  4. Vue.js-Day06-PM【项目实战(附带 完整项目源码)-day01-pm:轮播组件(使用、安装依赖、具体操作过程)、favicon详细制作】
  5. javaweb实现文件上传,前端与后台的结合实现
  6. 大话设计模式—中介者模式
  7. 拔染印花几点注意事项
  8. 关于在数据库中存储层次数据的一点看法与理解
  9. 如何修改hosts文件
  10. android 相对布局(RelativeLayout)
  11. BIOS学习之Beyong Bios
  12. 产品营销策划方案:6个创意来源
  13. 最新!抖音运营吸粉攻略分享
  14. 初学者必学:绘画初学者如何设计人物?
  15. 【DFS专题训练】踏青 C++程序题 连通块问题
  16. 简历上敢写自己熟练运用Spring,那这些问题你清楚嘛?
  17. PTA查找最后一个250 (20 分)
  18. 华为荣耀3C 4G全机型 TOS 第二版
  19. 计算机网络的前沿,计算机网络前沿研究.PDF
  20. 聊聊 JS 断点的实现

热门文章

  1. 一个简单的各异向性滤波实现和应用
  2. 操作系统——廉价磁盘冗余阵列
  3. DQUERY - D-query 普通莫队
  4. 黄桃罐头有营养吗?怎么做?
  5. 斩获BAT-offer的阿里大佬的腾讯百度实习经历分享
  6. Android Studio使用Wifi调试安装程序
  7. |,^,(按位或,按位异或,按位与)
  8. m3u8 文件格式详解
  9. 基于Java毕业设计校园疫情防控管理软件源码+系统+mysql+lw文档+部署软件
  10. MySQL修改数据(超详细)