注意:这实际上是一篇CustomPaint的使用教程!!git
源码地址:github.com/yumi0629/Fl…github
在Flutter中,CustomPaint
就像是Android中的Paint同样,能够用它绘制出各类各样的自定义图形。确实,Paint的使用比较复杂,我以为直接讲API的话也太无聊了,要记住Paint的用法,仍是本身动手画一个比较实在。
那为何是画一个CircleProgressBar呢?其实这个控件原本是为了交做业的,以前在讲Hero的时候留了一个小练习,里面有一个页面,有一个很炫酷的圆形ProgressBar选择器,当时为了偷懒我就没写(不要打我),因此如今来补交了。在写这个CircleProgressBar的时候发现,CustomPaint
中基本的API都使用到了,画圆、画弧线、画布旋转、Paint的各类属性的意义等等知识点都有涉及到。因此说,看完这篇文章,你绝对能够本身动手尝试画一些炫酷的UI控件来!
国际惯例,先上效果图:canvas
const CustomPaint({
Key key,
this.painter,
this.foregroundPainter,
this.size = Size.zero,
this.isComplex = false,
this.willChange = false,
Widget child,
})
复制代码
CustomPaint
是一个继承自SingleChildRenderObjectWidget
的控件,因此注意,不能用setState的方式来刷新它!!painter
就是咱们的主绘制工具,它是一个CustomPainter
;foregroundPainter
是用来绘制前景的工具;size
为画布大小,这个size会传递给Painter
;isComplex
和willChange
是告诉Flutter你的CustomPaint
是否复杂到须要使用cache相关的功能;child
属性咱们通常不填,即便你是想要在你的CustomPaint
上添加一些其余的布局,也不建议放在child属中性,由于你会发现你并不会获得你想要的结果。
全部的绘制都是发生在Painter里面的,绘制的代码写在咱们的自定义CustomPainter
中:bash
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属性,它包含了基本的画布大小信息。
真正地绘制则是经过canvas
和Paint
来实现的,咱们将定义好了的Paint画笔传递给canvas.drawXXX()
方法,这个方法会告诉Flutter咱们须要绘制一个什么东西,是一个圆呢、仍是一条线呢?
一些经常使用的canvas
绘制API:app
// 绘制弧线
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
属性:ide
color:画笔颜色
style:绘制模式,画线 or 充满
maskFilter:绘制完成,尚未被混合到布局上时,添加的遮罩效果,好比blur效果
strokeWidth:线条宽度
strokeCap:线条结束时的绘制样式
shader:着色器,通常用来绘制渐变效果或ImageShader
复制代码
GestureDetector
来实现就能够了,在
onPanUpdate
回调中实时刷新进度条与小圆点的位置,这里面须要注意的地方是可触摸区域的计算。
绘制所须要的变量基本都标注在上图中了,圆心坐标就是整块画布的中心点,咱们定义为(center,center)
,其中center = size.width * 0.5
。小圆点的半径定义为dotRadius
。灰色实线部分为底部圆环,progressBar的宽度为红色虚线部分所示,其大小应该比底部圆环略大,至于大多少,你能够本身定义。在本次的例子中,我将灰色实线与红色虚线之间的部定义为radiusOffset = dotRadius * 0.4
,这个值尽可能不要写死,那么radiusOffset*2
就是progressBar宽度比底部圆环大的值。innerRadius
和outRadius
分别为底部圆环的内/外半径,大小如图上所示(纯数学知识,不解释)。而后咱们能够根据innerRadius
和outRadius
计算出progressBar宽度progressWith = outerRadius - innerRadius + radiusOffset
。drawRadius
是一个大小为画布宽度的一半减去小圆点半径的变量,这个变量在绘制progressBar和小圆点的时候颇有用,用来肯定progressBar和小圆点的位置。函数
底部圆环的绘制很是简单,实际上就是画一个圆。为何说画圆环和画圆会是同样的呢?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
,不然画笔会默认充满内部,那么你绘制出来的就是一个圆了。 布局
绘制进度条实际上就是绘制圆弧,咱们使用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
就是咱们的画笔。
补充:弧度与角度的弧线转换:ui
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)
,上述代码能够绘制出一个基础的圆弧。可是咱们会发现,圆弧的两端是平的,很影响美观,这时候就须要用到paint
的strokeCap
属性了。
paint
设置为
StrokeCap.round
,就能获得一个最基本的进度条了。
paint
的
shader
属性来实现:
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.
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);
复制代码
那么到此为止,咱们的进度条部分也绘制完成了。
绘制小圆点就比较简单了,只要计算出小圆点的圆心位置就能够了,纯初中数学计算,本身拿纸画画就知道啦。绘制函数依然是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);
复制代码
绘制阴影有两种方法,实现出来的效果也不太同样。
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就完成了。
在实际运行时,若是角度太小时,会出现下面的状况:
进度的监听能够经过暴露的回调progressChanged(double value)
获得,范围是(0.0~1.0)
。