效果圖
今天周末,收拾東西的時候,發現了一個尺子,我們叫它量角器,我記得上學的時候,借給一個女同學這樣的量角器,到現在還沒有還我,啥意思嗎?還還我不?
看來是沒戲了,同學嗎?那么小氣干嘛?不如自己畫一個?畢竟我是最會編程的電工嗎!
在自定義量角器之前,我先說下,小樣就是小樣,本著實現效果為目的,當然可能會有更好或者更優的方式,就像做數學題目一樣,答案只有一個,但是解答思路有很多種,當然有好的建議和方式下面留言哦!
廢話不多說,走起!
觀察量角器
拿起桌子上的量角器,看了看,想了想,欸?我還是不記得借我尺子的那個同學叫啥來著?
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,
);
}
簡單繪制一個坐標系,為了更方便了解當前的畫布情況。自定義完畢,刪了即可。
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,
);
- 繪制一個圓弧
起始角度為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方法是一個角度到弧度的轉換。繪制結果是這樣的。
通過效果我們可以知道起始0度是從水平開始的,掃描是順時針掃描的。如果我們定義的起始不是0度呢?
起始角度為正數:
canvas.drawArc(rect, degToRad(30), degToRad(150), false, paint);
起始角度為負數:
canvas.drawArc(rect, degToRad(-90), degToRad(180), false, paint);
通過上面的了解,大概了解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);
}
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);
看到坐標系圓點移動到了(radius, radius)上了。
執行2行代碼:
canvas.translate(radius, radius);
canvas.rotate(degToRad(-90));
drawXY(canvas);
上圖就是執行完移動和旋轉后坐標系的位置。這個時候我們可以繪制第一個刻度,也就是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,
);
看到左下角那個紅色橫線了嗎,這個就是0刻度線,那么1刻度線如何繪制呢?我們可以想下,如果我們想保持剛才繪制0刻度和1刻度的代碼不變,是不是只要把這個半圓逆時針旋轉1度就可以了,你想想是不是呢?
但是我們不好旋轉半圓啊?怎么搞,反過來想,我們可以順時針旋轉畫布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,
);
如我們所希望的樣子,呦西,趁勢追擊,直接一步到位。
/*
* 繪制刻度
*/
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,
);
}
注意:上面代碼中出現的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));
}
這里主要是對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);
}
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,
);
}
什么鬼?不好看,下面那個輔助線起點太多時,導致比較的密集,所以我想優化下,在搞個小半圓給他蓋住,不讓別人知道你的丑。
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);
}
搞定!文章書寫不易,多多關注!