效果图
image.png今天周末,收拾东西的时候,发现了一个尺子,我们叫它量角器,我记得上学的时候,借给一个女同学这样的量角器,到现在还没有还我,啥意思吗?还还我不?
image.png看来是没戏了,同学吗?那么小气干嘛?不如自己画一个?毕竟我是最会编程的电工吗!
image.png在自定义量角器之前,我先说下,小样就是小样,本着实现效果为目的,当然可能会有更好或者更优的方式,就像做数学题目一样,答案只有一个,但是解答思路有很多种,当然有好的建议和方式下面留言哦!
废话不多说,走起!
观察量角器
拿起桌子上的量角器,看了看,想了想,欸?我还是不记得借我尺子的那个同学叫啥来着?
image.png
Sorry!
这个量角器吗?有这几个特征。
- 半圆形(里面有4个半圆)
- 刻度线(长的、中等的、短的)
- 有刻度值(正向,反向,注:这里0和180度省去,别问为啥,因为不好看)
- 测量辅助线(10的倍数)
像这种纯绘制的自定义基本上就是考验对Canvas API使用和数学知识,下面使用Flutter实现。
具体实现
创建Widget
1、StatelessWidget Or StatefulWidget
其实这个区分跟简单,当一个静态的,没有状态改变的自定义就使用StatelessWidget,否则使用StatefulWidget。因为尺子是一个静态的,一旦绘制完毕就不需要去改变了,所以说我们直接创建一个 StatelessWidget 就可以,
//量角器Widget
class SemiCircleRulerWidget extends StatelessWidget {
const SemiCircleRulerWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ...
}
}
2、尺寸如何定义
我们刚开始一定会考虑这个宽高是如何定义,到底是直接外面传进来?还是怎么办?我这里直接采取使用LayoutBuilder,通过LayoutBuilder,我们可以在布局过程中拿到父组件传递的约束信息,然后我们可以根据约束信息动态的构建不同的布局。
LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
double radius;
//当宽的一半大于等于高的时候,采取高来作为半径。
if (constraints.maxWidth / 2 >= constraints.maxHeight) {
radius = constraints.maxHeight;
}
//当宽的一半小于高的时候,采取宽的一半作为半径。
else {
radius = constraints.maxWidth / 2;
}
//宽 = 2*半径, 高 = 半径
var size = Size(radius * 2, radius);
return CustomPaint(
size: size,
painter: SemiCircleRulerCustomPainter(radius),
);
},
);
这里简单做了一下判断,为了在已有空间里绘制最大
- 当宽的一半大于等于高的时候,采取高来作为半径。
- 当宽的一半小于高的时候,采取宽的一半作为半径。
获取半径后,我们就可以为我们的CustomPaint设置大小了。
- 宽 = 2*半径, 高 = 半径
3、创建CustomPainter
class SemiCircleRulerCustomPainter2 extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
...
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
shouldRepaint返回ture表示需要重绘,返回false表示不需要重绘。所以这里我们直接返回false就可以。
4、paint方法
4.1 坐标系
有的时候,我们要是经常不写这种自定义的话,很容易忘记这个坐标系和画布在调用一些api之后的状态是啥样子的?所以我一般是这么做的,先写个绘制坐标系为了查看当时的坐标系情况。
/*
* 绘制坐标系( X轴 Y轴) 为了查看坐标系位置
*/
void drawXY(Canvas canvas) {
//X轴
canvas.drawLine(
const Offset(0, 0),
const Offset(300, 0),
Paint()
..color = Colors.green
..strokeWidth = 3,
);
//Y轴
canvas.drawLine(
const Offset(0, 0),
const Offset(0, 300),
Paint()
..color = Colors.red
..strokeWidth = 3,
);
}
image.png
简单绘制一个坐标系,为了更方便了解当前的画布情况。自定义完毕,删了即可。
4.2 绘制量角器雏形
量角器是一个半圆,所以我们需要调用的API是
drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint)
-
rect:定义承载圆弧形状的矩形。通过设置该矩形可以指定圆弧的位置和大小。
-
startAngle: 设置圆弧是从哪个角度顺时针绘画的。顺时针为正,逆时针为负(注意:这里是弧度值)
-
sweepAngle: 设置圆弧顺时针扫过的角度。(注意:这里是弧度值)
-
useCenter: 绘制的时候是否使用圆心,我们绘制圆弧的时候设置为false,如果设置为true, 并且当前画笔的描边属性设置为Paint.Style.FILL的时候,画出的就是扇形。
-
paint: 指定绘制的画笔。
为了更好了解这个API,我们来看下案例
- 创建一个正方形
Rect rect = const Rect.fromLTWH(100, 100, 300, 300);
canvas.drawRect(
rect,
Paint()
..color = Colors.black
..style = PaintingStyle.stroke
..strokeWidth = 3,
);
image.png
- 绘制一个圆弧
起始角度为0:
//绘制一个圆弧,从0度到90度
var paint = Paint()
..color = Colors.red
..style = PaintingStyle.stroke
..strokeWidth = 3;
canvas.drawArc(rect, degToRad(0), degToRad(90), false, paint);
//角度转换为弧度
double degToRad(num deg) => deg * (pi / 180.0);
上面代码中degToRad方法是一个角度到弧度的转换。绘制结果是这样的。
image.png
通过效果我们可以知道起始0度是从水平开始的,扫描是顺时针扫描的。如果我们定义的起始不是0度呢?
起始角度为正数:
canvas.drawArc(rect, degToRad(30), degToRad(150), false, paint);
image.png
起始角度为负数:
canvas.drawArc(rect, degToRad(-90), degToRad(180), false, paint);
image.png
通过上面的了解,大概了解drawArc绘制情况了。
下面直接绘制量角器
/*
* 绘制表框(半圆)
*/
void drawBorder(Canvas canvas, Size size) {
Rect rect = Rect.fromCircle(
center: Offset(radius, radius),
radius: radius - borderStrokeWidth / 2,
);
//第四个参数设置为true,因为尺子是闭合的
canvas.drawArc(rect, -pi, pi, true, borderPaint);
}
image.png
4.3 绘制刻度线
绘制线使用的API是:
drawLine(Offset p1, Offset p2, Paint paint)
2点确定一条直线,所以p1 p2就是2点的坐标,只要我们按照我们需要传入2个坐标即可,这里就不单独案例说明了,直接走起。
- 定位:我想从半圆的左下角开始绘制刻度线。
- 绘制刻度线:180个刻度,绘制每个刻度,其中10的倍数为大刻度,5结尾的刻度为中刻度,其他为小刻度,这里0和180度我们省略,因为绘制的话和圆弧底线重叠。
定位
这里说的定位,意思是操作画布来达到我想要绘制的起点,方便我绘制,因为我想在左下角开始绘制刻度线,所以,我执行下面的操作。
//画布移动到(radius, radius)点
canvas.translate(radius, radius);
//画布旋转-90度
canvas.rotate(degToRad(-90));
这2个操作后画布变成啥样子了,看我们的坐标系就明白了。
执行一行代码:
canvas.translate(radius, radius);
drawXY(canvas);
image.png
看到坐标系圆点移动到了(radius, radius)上了。
执行2行代码:
canvas.translate(radius, radius);
canvas.rotate(degToRad(-90));
drawXY(canvas);
image.png
上图就是执行完移动和旋转后坐标系的位置。这个时候我们可以绘制第一个刻度,也就是0度刻度线,如果我们需要绘制刻度线为20长的刻度线怎么做呢?
通过坐标系我们可以知道,0刻度的起始位置 X轴是0,Y轴是-radius(为了好理解,这里面没有考虑画笔的宽度啊,后面绘制会考虑),如果我们要绘制20长度刻度的话,两点坐标可以为:
- Offset(0.0, -(radius - borderStrokeWidth / 2))
- Offset(0.0, -(radius - borderStrokeWidth / 2) + 20),
这里borderStrokeWidth是画笔的宽度,因为要考虑画笔所以我们需要减去。
canvas.drawLine(
Offset(0.0, -(radius - borderStrokeWidth / 2)),
Offset(0.0, -(radius - borderStrokeWidth / 2) + 20),
scalePaint,
);
image.png
看到左下角那个红色横线了吗,这个就是0刻度线,那么1刻度线如何绘制呢?我们可以想下,如果我们想保持刚才绘制0刻度和1刻度的代码不变,是不是只要把这个半圆逆时针旋转1度就可以了,你想想是不是呢?
image.png
但是我们不好旋转半圆啊?怎么搞,反过来想,我们可以顺时针旋转画布1刻度可以达到一样的效果。来试试
//绘制0刻度
canvas.drawLine(
Offset(0.0, -(radius - borderStrokeWidth / 2)),
Offset(0.0, -(radius - borderStrokeWidth / 2) + 20),
scalePaint,
);
//顺时针旋转 1度
canvas.rotate(degToRad(1));
//查看坐标系
drawXY(canvas);
//绘制1刻度
canvas.drawLine(
Offset(0.0, -(radius - borderStrokeWidth / 2)),
Offset(0.0, -(radius - borderStrokeWidth / 2) + 20),
scalePaint,
);
image.png
image.png
如我们所希望的样子,呦西,趁势追击,直接一步到位。
/*
* 绘制刻度
*/
void drawScale(Canvas canvas, Size size) {
canvas.save();
canvas.translate(radius, radius);
canvas.rotate(degToRad(-90));
for (int index = 1; index < 180; index++) {
//旋转角度
canvas.rotate(degToRad(1));
//大刻度
if (index % 10 == 0) {
//绘制最长刻度
drawLongLine(canvas, size);
}
//中刻度
else if (index % 5 == 0) {
//绘制中刻度
drawMiddleLine(canvas, size);
}
//小刻度
else {
//绘制小刻度
drawShortLine(canvas, size);
}
}
canvas.restore();
}
/*
* 绘制长线
*/
void drawLongLine(Canvas canvas, Size size) {
canvas.drawLine(
Offset(0.0, -(radius - borderStrokeWidth / 2)),
Offset(0.0, -(radius - borderStrokeWidth / 2) + longScaleSize),
scalePaint,
);
}
/*
* 绘制中线
*/
void drawMiddleLine(Canvas canvas, Size size) {
canvas.drawLine(
Offset(0.0, -(radius - borderStrokeWidth / 2)),
Offset(0.0, -(radius - borderStrokeWidth / 2) + middleScaleSize),
scalePaint,
);
}
/*
* 绘制短线
*/
void drawShortLine(Canvas canvas, Size size) {
canvas.drawLine(
Offset(0.0, -(radius - borderStrokeWidth / 2)),
Offset(0.0, -(radius - borderStrokeWidth / 2) + shortScaleSize),
scalePaint,
);
}
image.png
注意:上面代码中出现的2行代码
- canvas.save();
- canvas.restore();
这2位是成对出现,不能单独使用,他们的目的就是在你操作画布(平移,旋转等)之前,先调用save()方法对当前画布状态的保存,当你操作画布绘制好图形后,在调用restore()还原之前画布的状态,不影响后面绘制操作。
4.3 绘制刻度值
刻度值是在10的倍数才绘制,所以我们直接可以在绘制刻度线代码中,在绘制长刻度线的地方,多绘制刻度值即可,还有就是这里有2种刻度值,一种顺时针,一种逆时针,我们只要顺时针直接采用当前角度显示即可,逆时针使用(180-当前角度)即可。
这里因为坐标系都是在对应位置,所以直接绘制就行。
/*
* 绘制数字
*/
void drawScaleNum(Canvas canvas, int i) {
//绘制最外圈刻度值
textPainter.text = TextSpan(
text: "$i",
style: TextStyle(
color: Colors.black,
fontSize: numTextSize,
));
textPainter.layout();
double textStarPositionX = -textPainter.size.width / 2;
double textStarPositionY = -radius + outNumSize;
textPainter.paint(canvas, Offset(textStarPositionX, textStarPositionY));
//绘制内圈刻度值
textPainter.text = TextSpan(
text: "${180 - i}",
style: TextStyle(
color: Colors.black,
fontSize: numTextSize,
));
textPainter.layout();
double textStarPositionX2 = -textPainter.size.width / 2;
double textStarPositionY2 = -radius + inNumSize;
textPainter.paint(canvas, Offset(textStarPositionX2, textStarPositionY2));
}
image.png
这里主要是对textPainter API的使用,这里主要1个注意点,就是2个刻度值的间距,通过控制y坐标来控制下就行,我这里 inNumSize=60,outNumSize=30,具体多少自己根据自己的审美修改就行,不做过多的解释。
4.3 绘制内部半圆
因为我们上面已经了解了绘制半圆的API,所以这里主要注意的点就是控制好半圆和刻度值的位置,避免重叠,其实也就是UI审美问题。
/*
* 绘制外半圆
*/
void drawOuterSemicircle(Canvas canvas, Size size) {
Rect rect = Rect.fromCircle(
center: Offset(radius, radius),
radius: radius - borderStrokeWidth / 2 - interSemicircleSize,
);
canvas.drawArc(rect, -pi, pi, true, semicirclePaint);
}
/*
* 绘制内半圆
*/
void drawInnerSemicircle(Canvas canvas, Size size) {
Rect rect = Rect.fromCircle(
center: Offset(radius, radius),
radius: radius - borderStrokeWidth / 2 - outerSemicircleSize,
);
canvas.drawArc(rect, -pi, pi, true, semicirclePaint);
}
image.png
4.4 绘制角度测量辅助线
角度测量辅助线也是10的倍数的刻度才绘制,所以也是在绘制刻度尺代码中绘制10刻度的if里加上绘制角度测量辅助线代码即可。辅助线起点:Offset(radius, radius),终点是:内半圆为结束,我们可以把每个半圆和刻度值距离边缘的距离定义成变量,方便后续其他地方使用。
void drawScale(Canvas canvas, Size size) {
...
for (int index = 1; index < 180; index++) {
...
//大刻度
if (index % 10 == 0) {
...
// 绘制刻度线
drawScaleLine(canvas, size);
}
...
}
canvas.restore();
}
/*
* 绘制刻度线(10倍数)
*/
void drawScaleLine(Canvas canvas, Size size) {
canvas.drawLine(
const Offset(0, 0),
Offset(0, -radius + scalePaintWidth + interSemicircleSize),
scalePaint,
);
}
image.png
image.png
什么鬼?不好看,下面那个辅助线起点太多时,导致比较的密集,所以我想优化下,在搞个小半圆给他盖住,不让别人知道你的丑。
4.5 绘制小半圆遮住你的美
我们直接画个半圆,并且使用PaintingStyle.fill类型盖住他,为了好看我在画个半圆作为边框,不愧是我啊。
/*
* 绘制最小的半圆
*/
void drawSmallSemicircle(Canvas canvas, Size size) {
//绘制半圆区域
Rect rect = Rect.fromCircle(
center: Offset(radius, radius - borderStrokeWidth / 2),
radius: radius / 10,
);
//这里先绘制半圆边框
canvas.drawArc(rect, -pi, pi, true, semicirclePaint);
//绘制白色半圆
semicirclePaint.color = Colors.white;
semicirclePaint.style = PaintingStyle.fill;
canvas.drawArc(rect, -pi, pi, true, semicirclePaint);
}
image.png
搞定!文章书写不易,多多关注!
image.png
网友评论