Android?Flutter繪制扇形圖詳解
簡(jiǎn)介
在開(kāi)發(fā)過(guò)程中通常會(huì)遇到一些不規(guī)則的UI,比如不規(guī)則的線條,多邊形,統(tǒng)計(jì)圖表等等,用那些通用組件通過(guò)組合的方式無(wú)法進(jìn)行實(shí)現(xiàn),這就需要我們自己進(jìn)行繪制。可以通過(guò)使用CuntomPaint組件并結(jié)合畫(huà)筆CustomPainter去進(jìn)行手動(dòng)繪制各種圖形。
CustomPaint介紹
CustomPaint是一個(gè)繼承SingleChildRenderObjectWidget的Widget,這里主要介紹幾個(gè)重要參數(shù):
child:CustomPaint的子組件。
painter: 畫(huà)筆,繪制的圖形會(huì)顯示在child后面。
foregroundPainter:前景畫(huà)筆,繪制的圖形會(huì)顯示在child前面。
size:繪制區(qū)域大小。
CustomPainter介紹
CustomPainter是一個(gè)抽象類,通過(guò)自定義一個(gè)類繼承自CustomPainter,重寫paint和shouldRepaint方法,具體繪制主要在paint方法里。
paint介紹
主要兩個(gè)參數(shù):
Canvas:畫(huà)布,可以用于繪制各種圖形。
Size:繪制區(qū)域的大小。
void paint(Canvas canvas, Size size)
shouldRepaint介紹
在Widget重繪前會(huì)調(diào)用該方法確定時(shí)候需要重繪,shouldRepaint返回ture表示需要重繪,返回false表示不需要重繪。
bool shouldRepaint(CustomPainter oldDelegate)
示例
這里我們通過(guò)繪制一個(gè)餅狀圖來(lái)演示繪制的整體流程。

使用CustomPaint
首先,使用CustomPaint,繪制大小為父組件最大值,傳入自定義painter。
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size.infinite,
painter: PieChartPainter(),
);
}
自定義Painter
自定義PieChartPainter繼承CustomPainter
class PieChartPainters extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return oldDelegate != this;
}
}
繪制
接著我們來(lái)實(shí)現(xiàn)paint方法進(jìn)行繪制
@override
void paint(Canvas canvas, Size size) {
//移動(dòng)到中心點(diǎn)
canvas.translate(size.width / 2, size.height / 2);
//繪制餅狀圖
_drawPie(canvas, size);
//繪制扇形分割線
_drawSpaceLine(canvas);
//繪制中心圓
_drawHole(canvas, size);
}
繪制餅狀圖
我們以整個(gè)畫(huà)布的中點(diǎn)為圓點(diǎn),然后計(jì)算出每個(gè)扇形的角度區(qū)域,通過(guò)canvas.drawArc繪制扇形。

void _drawPie(Canvas canvas, Size size) {
var startAngle = 0.0;
var sumValue = models.fold<double>(0.0, (sum, model) => sum + model.value);
for (var model in models) {
Paint paint = Paint()
..style = PaintingStyle.fill
..color = model.color;
var sweepAngle = model.value / sumValue * 360;
canvas.drawArc(Rect.fromCircle(radius: model.radius, center: Offset.zero),
startAngle * pi / 180, sweepAngle * pi / 180, true, paint);
//為每一個(gè)區(qū)域繪制延長(zhǎng)線和文字
_drawLineAndText(
canvas, size, model.radius, startAngle, sweepAngle, model);
startAngle += sweepAngle;
}
}
繪制延長(zhǎng)線以及文本
延長(zhǎng)線的起點(diǎn)為扇形區(qū)域邊緣中點(diǎn)位置,長(zhǎng)度為一個(gè)固定的長(zhǎng)度,轉(zhuǎn)折點(diǎn)坐標(biāo)通過(guò)半徑加這個(gè)固定長(zhǎng)度和三角函數(shù)進(jìn)行計(jì)算,然后通過(guò)轉(zhuǎn)折點(diǎn)的位置決定橫線終點(diǎn)的方向,而橫線的長(zhǎng)度則根據(jù)文字的寬度決定,然后通過(guò)canvas.drawLine進(jìn)行繪制直線。
文本繪制使用TextPainter.paint進(jìn)行繪制,paint方法里面最終是通過(guò)canvas.drawParagraph進(jìn)行繪制的。
最后再在文字的前面通過(guò)canvas.drawCircle繪制一個(gè)小圓點(diǎn)。

void _drawLineAndText(Canvas canvas, Size size, double radius,
double startAngle, double sweepAngle, PieChartModel model) {
var ratio = (sweepAngle / 360.0 * 100).toStringAsFixed(2);
var top = Text(model.name);
var topTextPainter = getTextPainter(top);
var bottom = Text("$ratio%");
var bottomTextPainter = getTextPainter(bottom);
// 繪制橫線
// 計(jì)算開(kāi)始坐標(biāo)以及轉(zhuǎn)折點(diǎn)的坐標(biāo)
var startX = radius * (cos((startAngle + (sweepAngle / 2)) * (pi / 180)));
var startY = radius * (sin((startAngle + (sweepAngle / 2)) * (pi / 180)));
var firstLine = radius / 5;
var secondLine =
max(bottomTextPainter.width, topTextPainter.width) + radius / 4;
var pointX = (radius + firstLine) *
(cos((startAngle + (sweepAngle / 2)) * (pi / 180)));
var pointY = (radius + firstLine) *
(sin((startAngle + (sweepAngle / 2)) * (pi / 180)));
// 計(jì)算坐標(biāo)在左邊還是在右邊
// 并計(jì)算橫線結(jié)束坐標(biāo)
// 如果結(jié)束坐標(biāo)超過(guò)了繪制區(qū)域,則改變結(jié)束坐標(biāo)的值
var marginOffset = 20.0; // 距離繪制邊界的偏移量
var endX = 0.0;
if (pointX - startX > 0) {
endX = min(pointX + secondLine, size.width / 2 - marginOffset);
secondLine = endX - pointX;
} else {
endX = max(pointX - secondLine, -size.width / 2 + marginOffset);
secondLine = pointX - endX;
}
Paint paint = Paint()
..style = PaintingStyle.fill
..strokeWidth = 1
..color = Colors.grey;
// 繪制延長(zhǎng)線
canvas.drawLine(Offset(startX, startY), Offset(pointX, pointY), paint);
canvas.drawLine(Offset(pointX, pointY), Offset(endX, pointY), paint);
// 文字距離中間橫線上下間距偏移量
var offset = 4;
var textWidth = bottomTextPainter.width;
var textStartX = 0.0;
textStartX =
_calculateTextStartX(pointX, startX, textWidth, secondLine, textStartX, offset);
bottomTextPainter.paint(canvas, Offset(textStartX, pointY + offset));
textWidth = topTextPainter.width;
var textHeight = topTextPainter.height;
textStartX =
_calculateTextStartX(pointX, startX, textWidth, secondLine, textStartX, offset);
topTextPainter.paint(canvas, Offset(textStartX, pointY - offset - textHeight));
// 繪制文字前面的小圓點(diǎn)
paint.color = model.color;
canvas.drawCircle(
Offset(textStartX - 8, pointY - 4 - topTextPainter.height / 2),
4,
paint);
}
繪制扇形分割線
在繪制完扇形之后,然后在扇形的開(kāi)始的那條邊上繪制一條直線,起點(diǎn)為圓點(diǎn),長(zhǎng)度為扇形半徑,終點(diǎn)的位置根據(jù)半徑和扇形開(kāi)始的那條邊的角度用三角函數(shù)進(jìn)行計(jì)算,然后通過(guò)canvas.drawLine進(jìn)行繪制。

void _drawSpaceLine(Canvas canvas) {
var sumValue = models.fold<double>(0.0, (sum, model) => sum + model.value);
var startAngle = 0.0;
for (var model in models) {
_drawLine(canvas, startAngle, model.radius);
startAngle += model.value / sumValue * 360;
}
}
void _drawLine(Canvas canvas, double angle, double radius) {
var endX = cos(angle * pi / 180) * radius;
var endY = sin(angle * pi / 180) * radius;
Paint paint = Paint()
..style = PaintingStyle.fill
..color = Colors.white
..strokeWidth = spaceWidth;
canvas.drawLine(Offset.zero, Offset(endX, endY), paint);
}
繪制內(nèi)部中心圓
這里可以通過(guò)傳入的參數(shù)判斷是否需要繪制這個(gè)圓,使用canvas.drawCircle進(jìn)行繪制一個(gè)與背景色一致的圓。

void _drawHole(Canvas canvas, Size size) {
if (isShowHole) {
holePath.reset();
Paint paint = Paint()
..style = PaintingStyle.fill
..color = Colors.white;
canvas.drawCircle(Offset.zero, holeRadius, paint);
}
}
觸摸事件處理
接下來(lái)我們來(lái)處理點(diǎn)擊事件,當(dāng)我們點(diǎn)擊某一個(gè)扇形區(qū)域時(shí),此扇形需要突出顯示,如下圖:

重寫hitTest方法
注意這個(gè)方法的返回值決定是否響應(yīng)事件。
默認(rèn)情況下返回null,事件不會(huì)向下傳遞,也不會(huì)進(jìn)行處理; 如果返回true則當(dāng)前組件進(jìn)行處理事件; 如果返回false則當(dāng)前組件不會(huì)響應(yīng)點(diǎn)擊事件,會(huì)向下一層傳遞;
我直接在這里處理點(diǎn)擊事件,通過(guò)該方法傳入的offset確定點(diǎn)擊的位置,如果點(diǎn)擊位置是在圓形區(qū)域內(nèi)并且不在中心圓內(nèi)則處理事件同時(shí)判斷所點(diǎn)擊的具體是哪個(gè)扇形,反之則恢復(fù)默認(rèn)狀態(tài)。
@override
bool? hitTest(Offset offset) {
if (oldTapOffset.dx==offset.dx && oldTapOffset.dy==offset.dy) {
return false;
}
oldTapOffset = offset;
for (int i = 0; i < paths.length; i++) {
if (paths[i].contains(offset) &&
!holePath.contains(offset)) {
onTap?.call(i);
oldTapOffset = offset;
return true;
}
}
onTap?.call(-1);
return false;
}
至此,我們通過(guò)onTap向上傳遞出點(diǎn)擊的是第幾個(gè)扇形,然后進(jìn)行處理,更新UI就可以了。
動(dòng)畫(huà)實(shí)現(xiàn)

這里通過(guò)Widget繼承ImplicitlyAnimatedWidget來(lái)實(shí)現(xiàn),ImplicitlyAnimatedWidget是一個(gè)抽象類,繼承自StatefulWidget,既然是StatefulWidget那肯定還有一個(gè)State,State繼承AnimatedWidgetBaseState(此類繼承自ImplicitlyAnimatedWidgetState),感興趣的小伙伴可以直接去看源碼
實(shí)現(xiàn)AnimatedWidgetBaseState里面的forEachTween方法,主要是用于來(lái)更新Tween的初始值。
@override
void forEachTween(TweenVisitor<dynamic>visitor) {
customPieTween = visitor(customPieTween, end, (dynamic value) {
return CustomPieTween(begin: value, end: end);
}) as CustomPieTween;
}
自定義CustomPieTween繼承自Tween,重寫lerp方法,對(duì)需要做動(dòng)畫(huà)的參數(shù)進(jìn)行處理
class CustomPieTween extends Tween<List<PieChartModel>> {
CustomPieTween({List<PieChartModel>? begin, List<PieChartModel>? end})
: super(begin: begin, end: end);
@override
List<PieChartModel> lerp(double t) {
List<PieChartModel> list = [];
begin?.asMap().forEach((index, model) {
list.add(model
..radius = lerpDouble(model.radius, end?[index].radius ?? 100.0, t));
});
return list;
}
double lerpDouble(double radius, double radius2, double t) {
if (radius == radius2) {
return radius;
}
var d = (radius2 - radius) * t;
var value = radius + d;
return value;
}
}
以上就是Android Flutter繪制扇形圖詳解的詳細(xì)內(nèi)容,更多關(guān)于Android Flutter扇形圖的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
如何玩轉(zhuǎn)Android矢量圖VectorDrawable
這篇文章主要教大家如何玩轉(zhuǎn)Android矢量圖VectorDrawable,對(duì)矢量圖感興趣的小伙伴們可以參考一下2016-05-05
Android開(kāi)發(fā)實(shí)戰(zhàn)鬧鐘項(xiàng)目
這篇文章主要介紹了Android開(kāi)發(fā)實(shí)戰(zhàn)鬧鐘項(xiàng)目,根據(jù)我們手機(jī)鬧鐘設(shè)計(jì)的一個(gè)鬧鐘APP,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-09-09
Android中用StaticLayout實(shí)現(xiàn)文本繪制自動(dòng)換行詳解
StaticLayout是android中處理文字換行的一個(gè)工具類,StaticLayout已經(jīng)實(shí)現(xiàn)了文本繪制換行處理,下面這篇文章主要介紹了Android中用StaticLayout實(shí)現(xiàn)文本繪制自動(dòng)換行的相關(guān)資料,需要的朋友可以參考。2017-03-03
item高度不同時(shí)Recyclerview獲取滑動(dòng)距離的方法
這篇文章主要介紹了item高度不同時(shí)Recyclerview獲取滑動(dòng)距離的方法,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2018-11-11
Android衛(wèi)星菜單效果的實(shí)現(xiàn)方法
這篇文章主要介紹了Android衛(wèi)星菜單效果的實(shí)現(xiàn)方法,需要的朋友可以參考下2017-05-05
Android TextView 去掉自適應(yīng)默認(rèn)的fontpadding的實(shí)現(xiàn)方法
這篇文章主要介紹了Android TextView 去掉自適應(yīng)默認(rèn)的fontpadding的實(shí)現(xiàn)方法的相關(guān)資料,希望通過(guò)本文大家能夠掌握這部分內(nèi)容,需要的朋友可以參考下2017-09-09
Android開(kāi)發(fā)之電話撥號(hào)器實(shí)例詳解
這篇文章主要介紹了Android開(kāi)發(fā)之電話撥號(hào)器,結(jié)合實(shí)例形式詳細(xì)分析了Android電話撥號(hào)器的實(shí)現(xiàn)步驟與具體代碼,并附帶了一個(gè)Android開(kāi)放電話撥號(hào)器的學(xué)習(xí)筆記,需要的朋友可以參考下2015-12-12

