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

栏目: IOS · Android · 发布时间: 7年前

内容简介:源码地址:在Flutter中,那为什么是画一个CircleProgressBar呢?其实这个控件本来是为了交作业的,之前在讲Hero的时候留了一个小练习,里面有一个页面,有一个很炫酷的圆形ProgressBar选择器,当时为了偷懒我就没写(不要打我),所以现在来补交来。在写这个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

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 {
  @override
  void paint(Canvas canvas, Size size) {
      // 绘制代码
  }
  
  @override
  bool 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
复制代码

绘制步骤分析

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 在画线的时候也是有宽度的,我们画一个有宽度的圆,不就是画一个圆环了吗?

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 ,否则画笔会默认充满内部,那么你绘制出来的就是一个圆了。

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

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

num degToRad(num deg) => deg * (pi / 180.0);
num radToDeg(num rad) => rad * (180.0 / pi);
复制代码
Flutter:教你用CustomPaint画一个自定义的CircleProgressBar
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 属性了。

Flutter:教你用CustomPaint画一个自定义的CircleProgressBar
我们将 paint 设置为 StrokeCap.round

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

Flutter:教你用CustomPaint画一个自定义的CircleProgressBar
接下来我们给进度条添加颜色,按照设计稿,我们需要添加一个渐变色。渐变色可以通过 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(径向渐变)。

Flutter:教你用CustomPaint画一个自定义的CircleProgressBar
很明显,我们需要用到的是 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.

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

!!。反正是一个圆弧嘛,那我把画布逆时针旋转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了呢?运行一下,啊嘞,怎么会这样纸?

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

这是我们给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);
复制代码
Flutter:教你用CustomPaint画一个自定义的CircleProgressBar

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

  • 小圆点外圈绘制

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

dotPaint
      ..color = dotEdgeColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = dotRadius * 0.3;
canvas.drawCircle(new Offset(dx, dy), dotRadius, dotPaint);
复制代码

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

Flutter:教你用CustomPaint画一个自定义的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;
  }
复制代码

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

Flutter:教你用CustomPaint画一个自定义的CircleProgressBar
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),进度条其实是被小圆点挡住了,看不到的,那么直接不绘制就可以了。


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Hit Refresh

Hit Refresh

Satya Nadella、Greg Shaw / HarperBusiness / 2017-9-26 / USD 20.37

Hit Refresh is about individual change, about the transformation happening inside of Microsoft and the technology that will soon impact all of our lives—the arrival of the most exciting and disruptive......一起来看看 《Hit Refresh》 这本书的介绍吧!

RGB转16进制工具
RGB转16进制工具

RGB HEX 互转工具

MD5 加密
MD5 加密

MD5 加密工具

XML、JSON 在线转换
XML、JSON 在线转换

在线XML、JSON转换工具