之前寫過一篇Android原生繪制曲線圖的博客,動(dòng)畫效果不要太絲滑矗烛,那么現(xiàn)在到了Flutter辅柴,該如何實(shí)現(xiàn)類似的效果呢?如果你熟悉android的Canvas,那么恭喜你, 你將很快上手Flutter的Canvas繪制各種圖形,因?yàn)閷?shí)現(xiàn)方式基本上與android是一模一樣
先看下要實(shí)現(xiàn)的基本效果:
Flutter中如果想要自定義繪制碌嘀,那么你需要用到 **CustomPaint **和 CustomPainter ; CustomPaint是Widget的子類涣旨,先來看下構(gòu)造方法
const CustomPaint({
Key key,
this.painter,
this.foregroundPainter,
this.size = Size.zero,
this.isComplex = false,
this.willChange = false,
Widget child,
}) :super(key: key, child: child);
我們只需要關(guān)心三個(gè)參數(shù),painter筏餐,foregroundPainter 和 child 开泽, 這里需要說明一下,painter 是繪制的 backgroud 層魁瞪,而child 是在backgroud之上繪制穆律,foregroundPainter 是在 child 之上繪制,所以這里就有了個(gè)層級(jí)關(guān)系导俘,這跟android里面的backgroud與foreground是一個(gè)意思峦耘,那這兩個(gè)painter的應(yīng)用場(chǎng)景是什么呢?假如你只是單純的想繪制一個(gè)圖形旅薄,只用painter就可以了辅髓,但是如果你想給繪制區(qū)域添加一個(gè)背景(顏色,圖片少梁,等等)洛口,這時(shí)候如果使用 painter是會(huì)有問題的,painter的繪制會(huì)被child 層覆蓋掉凯沪,此時(shí)你只需要將painter替換成foregroundPainter,然會(huì)顏色或者圖片傳遞給child即可第焰。
如果是Android繪制幾何圖形,應(yīng)該是重寫View的onLayout() 和 onDraw方法妨马,但是Flutter實(shí)現(xiàn)繪制挺举,必須繼承CustomPainter并重寫 paint(Canvas canvas, Size size)和 shouldRepaint (CustomPainter oldDelegate) 方法 ,第一個(gè)參數(shù)canvas就是我們繪制的畫布了(跟Android一模一樣)烘跺,paint第二個(gè)參數(shù)Size就是上面CustomPaint構(gòu)造方法傳入的size, 決定繪制區(qū)域的寬高信息
既然Size已經(jīng)確定了湘纵,現(xiàn)在就定義下繪制區(qū)域的邊界,一般我做類似的UI,都會(huì)定義一個(gè)最基本的padding, 一般取值為16 滤淳, 因?yàn)槔L制的內(nèi)容與坐標(biāo)軸之間需要找到一個(gè)基準(zhǔn)線梧喷,這樣更容易繪制,而且調(diào)試邊距也很靈活
double startX, endX, startY, endY;//定義繪制區(qū)域的邊界
static const double basePadding = 16; //默認(rèn)的邊距
double fixedHeight, fixedWidth; //去除padding后曲線的真實(shí)寬高
bool isShowXyRuler; //是否顯示xy刻度
List<ChatBean> chatBeans;//數(shù)據(jù)源
class ChatBean {
String x;
double y;
int millisSeconds;
Color color;
ChatBean({@required this.x, @required this.y, this.millisSeconds, this.color});
}
然后在paint()方法中拿到Size,確定繪制區(qū)域的坐標(biāo)
///計(jì)算邊界
void initBorder(Size size) {
print('size - - > $size');
this.size = size;
startX = yNum > 0 ? basePadding * 2.5 : basePadding * 2; //預(yù)留出y軸刻度值所占的空間
endX = size.width - basePadding * 2;
startY = size.height - (isShowXyRuler ? basePadding * 3 : basePadding);
endY = basePadding * 2;
fixedHeight = startY - endY;
fixedWidth = endX - startX;
maxMin = calculateMaxMin(chatBeans);
}
maxMin是定義存儲(chǔ)曲線中最大值和最小值的
///計(jì)算極值 最大值,最小值
List<double> calculateMaxMin(List<ChatBean> chatBeans) {
if (chatBeans == null || chatBeans.length == 0) return [0, 0];
double max = 0.0, min = 0.0;
for (ChatBean bean in chatBeans) {
if (max < bean.y) {
max = bean.y;
}
if (min > bean.y) {
min = bean.y;
}
}
return [max, min];
}
初始化畫筆 .. 是dart中的獨(dú)特語法脖咐,代表使用對(duì)象的返回值調(diào)用屬性或方法
var paint = Paint()
..isAntiAlias = true//抗鋸齒
..strokeWidth = 2
..strokeCap = StrokeCap.round//折線連接處圓滑處理
..color = xyColor
..style = PaintingStyle.stroke;//描邊
繪制坐標(biāo)軸伤柄,這里在確定好的邊界基礎(chǔ)上再次xy軸橫向和縱向各自增加一倍的padding,不然顯得太緊湊
canvas.drawLine(Offset(startX, startY),Offset(endX + basePadding, startY), paint); //x軸
canvas.drawLine(Offset(startX, startY),Offset(startX, endY - basePadding), paint); //y軸
繪制 X 軸刻度,定義為最多繪制7組數(shù)據(jù) 文搂,rulerWidth就是刻度的長(zhǎng)度定義為8
int length = chatBeans.length > 7 ? 7 : chatBeans.length; //最多繪制7個(gè)
double DW = fixedWidth / (length - 1); //兩個(gè)點(diǎn)之間的x方向距離
double DH = fixedHeight / (length - 1); //兩個(gè)點(diǎn)之間的y方向距離
for (int i = 0; i < length; i++) {
///繪制x軸文本
TextPainter(
textAlign: TextAlign.center,
ellipsis: '.',
text: TextSpan(
text: chatBeans[i].x,
style: TextStyle(color: fontColor, fontSize: fontSize)),
textDirection: TextDirection.ltr)
..layout(minWidth: 40, maxWidth: 40)
..paint(canvas, Offset(startX + DW * i - 20, startY + basePadding));
///x軸刻度
canvas.drawLine(Offset(startX + DW * i, startY),Offset(startX + DW * i, startY - rulerWidth), paint);
}
這里要說明一點(diǎn)适刀,F(xiàn)lutter繪制文本,并不能像android那樣調(diào)用canvas.drawText () , 而是通過TextPainter來渲染的;
構(gòu)造TextPainter 你必須指定文字的方向 textDirection 和 寬度 layout ,最后調(diào)用paint方法煤蹭,指定坐標(biāo)進(jìn)行繪制;
繪制 Y 軸刻度笔喉,y軸的刻度數(shù)量并不需要跟隨數(shù)據(jù)源的長(zhǎng)度取视,只需要按照一定數(shù)量(**yNum **)平分y軸最大值即可。
int yLength = yNum + 1; //包含原點(diǎn),所以 +1
double dValue = maxMin[0] / yNum; //一段對(duì)應(yīng)的值
double dV = fixedHeight / yNum; //一段對(duì)應(yīng)的高度
for (int i = 0; i < yLength; i++) {
///繪制y軸文本常挚,保留1位小數(shù)
var yValue = (dValue * i).toStringAsFixed(isShowFloat ? 1 : 0);
TextPainter(
textAlign: TextAlign.center,
ellipsis: '.',
maxLines: 1,
text: TextSpan(
text: '$yValue',
style: TextStyle(color: fontColor, fontSize: fontSize)),
textDirection: TextDirection.rtl)
..layout(minWidth: 40, maxWidth: 40)
..paint(canvas, Offset(startX - 40, startY - dV * i - fontSize / 2));
///y軸刻度
canvas.drawLine(Offset(startX, startY - dV * (i)),Offset(startX + rulerWidth, startY - dV * (i)), paint);
}
現(xiàn)在坐標(biāo)軸和刻度已經(jīng)繪制完成了作谭,基本上與原生一致,只是代碼方式有些區(qū)別奄毡,接下來的曲線也是一模一樣的折欠,繪制貝塞爾曲線其實(shí)也不難,主要是找到起點(diǎn)和兩個(gè)坐標(biāo)之間的輔助點(diǎn)吼过, 貝塞爾曲線的原理可以參考這里
path.cubicTo(double x1, double y1, double x2, double y2, double x3, double y3)
path = Path();
double preX, preY, currentX, currentY;
int length = chatBeans.length > 7 ? 7 : chatBeans.length;
double W = fixedWidth / (length - 1); //兩個(gè)點(diǎn)之間的x方向距離
遍歷數(shù)據(jù)源的第一個(gè)元素時(shí)锐秦,需要做個(gè)判斷,index=0時(shí)盗忱,需要將path move到此處
if (i == 0) {
path.moveTo(startX, (startY - chatBeans[i].y / maxMin[0] * fixedHeight));
continue;
}
添加后面的坐標(biāo)時(shí)酱床,需要找輔助點(diǎn)
currentX = startX + W * i;
preX = startX + W * (i - 1);
preY = (startY - chatBeans[i - 1].y / maxMin[0] * fixedHeight);
currentY = (startY - chatBeans[i].y / maxMin[0] * fixedHeight);
path.cubicTo(
(preX + currentX) / 2, preY,
(preX + currentX) / 2, currentY,
currentX, currentY
);
如果是要畫折線而非曲線,第一步還是path.moveTo 趟佃,折線不需要找輔助點(diǎn)扇谣,所以后續(xù)可以直接添加坐標(biāo),path.lineTo
最后將path繪制出來
canvas.drawPath(newPath, paint);
雖然曲線已經(jīng)成功繪制闲昭,但是這樣顯得很枯燥罐寨,如果可以看到繪制過程那就會(huì)更加有趣味性,這時(shí)候就需要通過動(dòng)畫來更新曲線的path的長(zhǎng)度了序矩,一般Android中我會(huì)用ValueAnimator.ofFloat(start ,end ) 來開啟一個(gè)動(dòng)畫 ,在Flutter中衩茸,動(dòng)畫也是非常簡(jiǎn)單實(shí)用
_controller = AnimationController(vsync: this, duration: widget.duration);
Tween(begin: 0.0, end: widget.duration.inMilliseconds.toDouble())
.animate(_controller)
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
print('繪制完成');
}
})
..addListener(() {
_value = _controller.value;//當(dāng)前動(dòng)畫值
setState(() {});
});
_controller.forward();
動(dòng)畫執(zhí)行過程中,我們會(huì)及時(shí)獲取到當(dāng)前的動(dòng)畫進(jìn)度 _value, 此時(shí)就需要一段完整的path跟隨動(dòng)畫值 等比繪制了 贮泞,之前在Android中我們可以用 PathMeasure 來測(cè)量path ,然后根據(jù)動(dòng)畫進(jìn)度不斷地截取幔烛,就實(shí)現(xiàn)了像貪吃蛇一樣的效果啃擦, 但是在Flutter中,我并沒有找到PathMeasure 這個(gè)類饿悬,相反的令蛉,PathMeasure 在Flutter竟然是個(gè)私有的類 _PathMeasure ,經(jīng)過一通百度 和 google狡恬,也沒有找到類似的案例珠叔。難道沒有人給造輪子,就必須要停止我前進(jìn)的步伐了嘛弟劲,不急祷安,顯然Path這個(gè)類里面有很多方法,就這樣我走上了一條反復(fù)測(cè)試的不歸路...
幸運(yùn)的是兔乞,在翻閱了google 官方Flutter api 后汇鞭,終于找到了突破口
哈哈凉唐,藏得還挺深吶,就是這個(gè) PathMetrics 類霍骄,path.computeMetrics() 的返回值 台囱,是用來將path解析成矩陣的一個(gè)工具
var pathMetrics = path.computeMetrics(forceClosed: false);
有個(gè)參數(shù) forceClosed , 表示是否要連接path的起始點(diǎn) 读整,我們這里當(dāng)然不要啦 簿训,computeMetrics方法返回的是PathMetrics對(duì)象,調(diào)用 toList () 可以獲取到 多個(gè)path組成的 List<PathMetric> ; 集合中的每個(gè)元素代表一段path的矩陣 米间, 奇怪强品,為什么是多個(gè)path 呢 ?车伞?择懂?
當(dāng)時(shí)我也是懵著猜測(cè)的,歷史總是驚人的相似另玖,被我給猜對(duì)了困曙,不曉得你們有沒有發(fā)現(xiàn),**Path有個(gè)方法可以添加多個(gè)Path , **
path.addPath(path, offset);
當(dāng)我每調(diào)用一次 addPath()或者 moveTo() 谦去,lsit . length就增加1慷丽,所以上面提到的多個(gè)path的集合 就不難理解了 ,因?yàn)槲覀冞@里只有一個(gè)path, 所以我們的 list 中只有一個(gè)元素 , 元素中包含一段path, 現(xiàn)在我們獲取到了描述path的矩陣PathMetric
PathMetric . length 就是這段path的長(zhǎng)度了鳄哭,唉要糊,為了找到你 ,我容易嗎 妆丘!
另外還有個(gè)關(guān)鍵的方法锄俄,可以將pathMetric按照給定的位置區(qū)間截取,最后返回這段path, 這就跟android中的PathMeasure.getSegment()是一樣
extractPath(double start勺拣, double end奶赠,{ bool startWithMoveTo:true }) → Path
給定起始和停止距離,返回中間段药有。
現(xiàn)在是時(shí)候?qū)⑶懊娅@取到的當(dāng)前動(dòng)畫值 value 用起來了毅戈,找到當(dāng)前path的length乘以value即是當(dāng)前path的最新長(zhǎng)度
var pathMetrics = path.computeMetrics(forceClosed: true);
var list = pathMetrics.toList();
var length = value * list.length.toInt();
Path newPath = new Path();
for (int i = 0; i < length; i++) {
var extractPath =list[i].extractPath(0, list[i].length * value, startWithMoveTo: true);
newPath.addPath(extractPath, Offset(0, 0));
}
canvas.drawPath(newPath, paint);
走到這里,好像跨過了山和大海愤惰,得了苇经,困死了,睡了宦言、睡了...
現(xiàn)在曲線和折線都已經(jīng)繪制完成了扇单,不過剛開始的demo里還有個(gè)漸變色的部分沒有完成,貌似有了漸變色以后奠旺,顯得不那么單調(diào)了令花,其實(shí)阻桅,我們繪圖所用到的Paint還有一個(gè)屬性shader,可以繪制線條或區(qū)域的漸變色兼都,LinearGradient可實(shí)現(xiàn)線性漸變的效果嫂沉,默認(rèn)為從左到右繪制,你可以通過begin和end屬性自定義繪制的方向扮碧,我們這里需要指定為從上至下趟章,并且顏色類型為數(shù)組的形式,所以你可以傳入多個(gè)顏色值來繪制
var shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
tileMode: TileMode.clamp,
colors: shaderColors)
.createShader(Rect.fromLTRB(startX, endY, startX, startY));
值得注意的是慎王,通過 createShader的方式創(chuàng)建shader蚓土,你需要指定繪制區(qū)域的邊界,我們這里要實(shí)現(xiàn)的是從上至下赖淤,所以就以y軸為基準(zhǔn)蜀漆,指定從上至下的繪制方向
既然是繪制漸變色,所以畫筆的樣式必須設(shè)置為填充狀態(tài)
Paint shadowPaint = new Paint();
shadowPaint
..shader = shader
..isAntiAlias = true
..style = PaintingStyle.fill;
另外咱旱,漸變色的區(qū)域我們是通過path來指定上面的邊界的确丢,所以我們還需要指定path下面部分的起點(diǎn)和終點(diǎn),這樣形成一個(gè)閉環(huán)吐限,才能確定出完整的區(qū)域
///從path的最后一個(gè)點(diǎn)連接起始點(diǎn)鲜侥,形成一個(gè)閉環(huán)
shadowPath
..lineTo(startX + fixedWidth * value, startY)
..lineTo(startX, startY)
..close();
canvas..drawPath(shadowPath, shadowPaint);
至此,即可實(shí)現(xiàn)帶有漸變色的曲線或者折線诸典,也許你有個(gè)疑問描函,畫折線為什么也要用path呢,不是可以直接drawLine嗎 狐粱?機(jī)智如我舀寓,添加到path以后,可以更方便的繪制肌蜻,添加動(dòng)畫也很方便; 另附上最終的實(shí)現(xiàn)效果互墓,至于觸摸操作就不打算闡述了,可以參考以下代碼
代碼已發(fā)布到 Dart https://pub.dev/flutter/packages?q=flutter_chart
GitHub倉(cāng)庫鏈接 https://github.com/good-good-study/flutter_chart