Flutter-用CustomPaint画一个自定义的CircleProgressBar

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

源码地址: github.com/yumi0629/Fl…

在Flutter中, CustomPaint 就像是 Android 中的Paint一样,可以用它绘制出各种各样的自定义图形。确实,Paint的使用比较复杂,我觉得直接讲API的话也太无聊了,要记住Paint的用法,还是自己动手画一个比较实在。

那为什么是画一个CircleProgressBar呢?其实这个控件本来是为了交作业的,之前在讲Hero的时候留了一个小练习,里面有一个页面,有一个很炫酷的圆形ProgressBar选择器,当时为了偷懒我就没写(不要打我),所以现在来补交来。在写这个CircleProgressBar的时候发现, CustomPaint 中基本的API都使用到了,画圆、画弧线、画布旋转、Paint的各种属性的意义等等知识点都有涉及到。所以说,看完这篇文章,你绝对可以自己动手尝试画一些炫酷的UI控件来!

国际惯例,先上效果图:

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

什么是CustomPaint

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

所有的绘制都是发生在Painter里面的,绘制的代码写在我们的自定义 CustomPainter 中:

我们需要重写 paint()shouldRepaint() 这两个方法,一个是绘制流程,一个是在刷新布局的时候告诉Flutter是否需要重绘。注意下 paint 方法中的size参数,就是我们在 CustomPaint 中定义的size属性,它包含了基本的画布大小信息。

真正地绘制则是通过 canvasPaint 来实现的,我们将定义好了的Paint画笔传递给 canvas.drawXXX() 方法,这个方法会告诉Flutter我们需要绘制一个什么东西,是一个圆呢、还是一条线呢?

一些常用的 canvas 绘制API:

一些常用的 Paint 属性:

绘制步骤分析

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

首先是静态进度条的绘制,我们先拆解这个CircleProgressBar为三部分:底部圆环、进度条和显示当前进度的小圆点。因为 Canvas的绘制顺序是按代码顺序一层一层往上叠加的 ,所以我们的绘制步骤应该是:绘制底部圆环——>绘制进度条——>绘制小圆点。

然后是手势拖动的实现,我们选用 GestureDetector 来实现就可以了,在 onPanUpdate

回调中实时刷新进度条与小圆点的位置,这里面需要注意的地方是可触摸区域的计算。

静态CircleProgressBar绘制

Flutter:教你用CustomPaint画一个自定义的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 在画线的时候也是有宽度的,我们画一个有宽度的圆,不就是画一个圆环了吗?

canvas.drawCircle(Offset c, double radius, Paint paint) 这个方法就是绘制一个圆,其中c为圆心坐标点,这个offset偏移值是以画布原点(左上角)为坐标轴中心点来计算的,很明显大小为 offsetCenter = Offset(center, center) ;radius为圆环半径,大小其实就是图上标示的 drawRadius ;paint就是我们的画笔,这里要注意,绘制圆环需要设置 style = PaintingStyle.stroke ,否则画笔会默认充满内部,那么你绘制出来的就是一个圆了。

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

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 就是我们的画笔。

补充:弧度与角度的弧线转换:

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

假设当前进度为 progress (范围为0.0~1.0),那么当前角度为 angle = 360.0 * progress ,当前弧度为 radians = degToRad(angle) ,上述代码可以绘制出一个基础的圆弧。但是我们会发现,圆弧的两端是平的,很影响美观,这时候就需要用到 paintstrokeCap 属性了。

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

我们将 paint 设置为 StrokeCap.round

,就能得到一个最基本的进度条了。

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

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

属性来实现:

Flutter提供了三种基础的用来绘制渐变效果的类:SweepGradient(扫描渐变)、LinearGradient(线性渐变)和RadialGradient(径向渐变)。

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

很明显,我们需要用到的是 SweepGradient

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

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

那么怎么解决呢?我想了很久之后决定采用一个曲线救国的方法,那就是: 旋转画布

!!。反正是一个圆弧嘛,那我把画布逆时针旋转90°不就行了嘛(这里还要注意,画布默认旋转中心为坐标轴原点,而且貌似不能更改,至少我没找到,所以需要旋转后再平移,对canvas的位置操作需要倒着写,所以实际代码是先写translate,再写rotate):

画到这里你是不是觉得已经很OK了呢?运行一下,啊嘞,怎么会这样纸?

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

这是我们给stroke设置了StrokeCap.round导致的,因为Flutter在给线绘制圆角时,是在线长的外面加了一段圆角,导致实际长度会超过我们定义的长度。那怎么办呢?还是曲线救国,我们在drawArc的时候,将起始角度往后偏移一段不就可以了吗?我们将这段偏移弧度定义为 offset ,其大小为 offset = asin(progressWidth * 0.5 / drawRadius) (怎么算出来的?数学问题,自己那张草稿纸画画就知道啦~)。

所以最终的绘制代码应该为:

那么到此为止,我们的进度条部分也绘制完成了。

Step 3 绘制小圆点

绘制小圆点就比较简单了,只要计算出小圆点的圆心位置就可以了,纯初中数学计算,自己拿纸画画就知道啦。绘制函数依然是 canvas.drawCircle ,因为是绘制圆,所以不需要更改PaintingStyle。

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:

2)使用paint的 MaskFilter.blur() 来绘制 :

这个方法其实是用来绘制毛玻璃效果的,用来绘制阴影,听起来也有些曲线救国的意味,但是官方注释中有一句话:

所以这个真的也是可以用来绘制阴影的,而且Flutter在绘制一些Button控件的时候也是使用来blur的效果来实现的。 MaskFilter.blur() 其实就是将你绘制的东西变模糊,所以我们可以绘制一个圆环,然后将其进行高斯模糊,造成一种加了“阴影”的假象。

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

两者绘制结果的区别很明显, canvas.drawShadow() 是将整个圆环作为一个整体,为其添加阴影;而 MaskFilter.blur() 其实就是绘制两个模糊的圆环,作为一种阴影的替代品。使用哪种方式绘制,还是取决于你需要什么样的效果。

  • 小圆点外圈绘制

这个没什么难度的,就是在小圆点外面再绘制一个圆环而已:

到此为止,一个静态的CircleProgressBar就绘制完成了:

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

添加手势控制

手势控制我们通过最简单的方式来实现,那就是在CircleProgressBar外面包裹一层 GestureDetector ,然后在 onPanUpdate 回调中刷新进度:

进度的记录我们依然是使用 AnimationController ,因为我们可以使用 controller.animateTo() 方法,很方便得将进度条从当前位置平滑地移动到目标位置:

接下来就是判断用户的触摸点是否在有效范围内,因为用户只有在触摸圆环的时候才应该触发手势,判断方法也很简单,那就是看系统反馈给我们的pointer位置收否位于圆环上。但是实际操作会有一个问题,那就是系统反馈的触摸点位置是一个全局的坐标点,坐标轴原点在屏幕的左上角,然后圆环在屏幕中的全局坐标我们无法知晓。好在Flutter为我们提供了一个全局坐标与局部坐标的转换方法:

拿到局部坐标后,通过计算触摸点与圆心的距离,是否在内、外半径范围内,就可以判断是否为有效触摸了(一般情况下触摸范围会比圆环更大一线,方便用户操作,所以我将validInnerRadius的值,设置地比widget.radius - widget.dotRadius更小一点):

接下来就是计算触摸点所在的角度了,要注意根据边来计算角度时,位于不同的象限,要做不同的处理:

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

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

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容