之前寫過一篇Android原生繪製曲線圖的博客,動畫效果不要太絲滑,那麼現在到了Flutter,該如何實現類似的效果呢?如果你熟悉android的Canvas,那麼恭喜你, 你將很快上手Flutter的Canvas繪製各種圖形,因爲實現方式基本上與android是一模一樣
先看下要實現的基本效果:
Flutter中如果想要自定義繪製,那麼你需要用到 CustomPaint 和 CustomPainter ; CustomPaint是Widget的子類,先來看下構造方法
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);
我們只需要關心三個參數,painter,foregroundPainter 和 child , 這裏需要說明一下,painter 是繪製的 backgroud 層,而child 是在backgroud之上繪製,foregroundPainter 是在 child 之上繪製,所以這裏就有了個層級關係,這跟android裏面的backgroud與foreground是一個意思,那這兩個painter的應用場景是什麼呢?假如你只是單純的想繪製一個圖形,只用painter就可以了,但是如果你想給繪製區域添加一個背景(顏色,圖片,等等),這時候如果使用 painter是會有問題的,painter的繪製會被child 層覆蓋掉,此時你只需要將painter替換成foregroundPainter,然會顏色或者圖片傳遞給child即可。
如果是Android繪製幾何圖形,應該是重寫View的onLayout() 和 onDraw方法,但是Flutter實現繪製,必須繼承CustomPainter並重寫 paint(Canvas canvas, Size size)和 shouldRepaint (CustomPainter oldDelegate) 方法 ,第一個參數canvas就是我們繪製的畫布了(跟Android一模一樣),paint第二個參數Size就是上面CustomPaint構造方法傳入的size, 決定繪製區域的寬高信息
既然Size已經確定了,現在就定義下繪製區域的邊界,一般我做類似的UI,都會定義一個最基本的padding, 一般取值爲16 , 因爲繪製的內容與座標軸之間需要找到一個基準線,這樣更容易繪製,而且調試邊距也很靈活
double startX, endX, startY, endY;//定義繪製區域的邊界
static const double basePadding = 16; //默認的邊距
double fixedHeight, fixedWidth; //去除padding後曲線的真實寬高
bool isShowXyRuler; //是否顯示xy刻度
List<ChatBean> chatBeans;//數據源
class ChatBean {
String x;
double y;
int millisSeconds;
Color color;
ChatBean({@required this.x, @required this.y, this.millisSeconds, this.color});
}
然後在paint()方法中拿到Size,確定繪製區域的座標
///計算邊界
void initBorder(Size size) {
print('size - - > $size');
this.size = size;
startX = yNum > 0 ? basePadding * 2.5 : basePadding * 2; //預留出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是定義存儲曲線中最大值和最小值的
///計算極值 最大值,最小值
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中的獨特語法,代表使用對象的返回值調用屬性或方法
var paint = Paint()
..isAntiAlias = true//抗鋸齒
..strokeWidth = 2
..strokeCap = StrokeCap.round//折線連接處圓滑處理
..color = xyColor
..style = PaintingStyle.stroke;//描邊
繪製座標軸,這裏在確定好的邊界基礎上再次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組數據 ,rulerWidth就是刻度的長度定義爲8
int length = chatBeans.length > 7 ? 7 : chatBeans.length; //最多繪製7個
double DW = fixedWidth / (length - 1); //兩個點之間的x方向距離
double DH = fixedHeight / (length - 1); //兩個點之間的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);
}
這裏要說明一點,Flutter繪製文本,並不能像android那樣調用canvas.drawText () , 而是通過TextPainter來渲染的,
構造TextPainter 你必須指定文字的方向 textDirection 和 寬度 layout ,最後調用paint方法,指定座標進行繪製
繪製 Y 軸刻度,y軸的刻度數量並不需要跟隨數據源的長度,只需要按照一定數量(yNum )平分y軸最大值即可
int yLength = yNum + 1; //包含原點,所以 +1
double dValue = maxMin[0] / yNum; //一段對應的值
double dV = fixedHeight / yNum; //一段對應的高度
for (int i = 0; i < yLength; i++) {
///繪製y軸文本,保留1位小數
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);
}
現在座標軸和刻度已經繪製完成了,基本上與原生一致,只是代碼方式有些區別,接下來的曲線也是一模一樣的,繪製貝塞爾曲線其實也不難,主要是找到起點和兩個座標之間的輔助點, 貝塞爾曲線的原理可以參考這裏
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); //兩個點之間的x方向距離
遍歷數據源的第一個元素時,需要做個判斷,index=0時,需要將path move到此處
if (i == 0) {
path.moveTo(startX, (startY - chatBeans[i].y / maxMin[0] * fixedHeight));
continue;
}
添加後面的座標時,需要找輔助點
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 ,折線不需要找輔助點,所以後續可以直接添加座標,path.lineTo
最後將path繪製出來
canvas.drawPath(newPath, paint);
雖然曲線已經成功繪製,但是這樣顯得很枯燥,如果可以看到繪製過程那就會更加有趣味性,這時候就需要通過動畫來更新曲線的path的長度了,一般Android中我會用ValueAnimator.ofFloat(start ,end ) 來開啓一個動畫 ,在Flutter中,動畫也是非常簡單實用
_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;//當前動畫值
setState(() {});
});
_controller.forward();
動畫執行過程中,我們會及時獲取到當前的動畫進度 _value, 此時就需要一段完整的path跟隨動畫值 等比繪製了 ,之前在Android中我們可以用 PathMeasure 來測量path ,然後根據動畫進度不斷地截取,就實現了像貪吃蛇一樣的效果, 但是在Flutter中,我並沒有找到PathMeasure 這個類,相反的,PathMeasure 在Flutter竟然是個私有的類 _PathMeasure ,經過一通百度 和 google,也沒有找到類似的案例。難道沒有人給造輪子,就必須要停止我前進的步伐了嘛,不急,顯然Path這個類裏面有很多方法,就這樣我走上了一條反覆測試的不歸路...
幸運的是,在翻閱了google 官方Flutter api 後,終於找到了突破口
哈哈,藏得還挺深吶,就是這個 PathMetrics 類,path.computeMetrics() 的返回值 ,是用來將path解析成矩陣的一個工具
var pathMetrics = path.computeMetrics(forceClosed: false);
有個參數 forceClosed , 表示是否要連接path的起始點 ,我們這裏當然不要啦 ,computeMetrics方法返回的是PathMetrics對象,調用 toList () 可以獲取到 多個path組成的 List<PathMetric> ; 集合中的每個元素代表一段path的矩陣 , 奇怪,爲什麼是多個path 呢 ???
當時我也是懵着猜測的,歷史總是驚人的相似,被我給猜對了,不曉得你們有沒有發現,Path有個方法可以添加多個Path ,
path.addPath(path, offset);
當我每調用一次 addPath()或者 moveTo() ,lsit . length就增加1,所以上面提到的多個path的集合 就不難理解了 ,因爲我們這裏只有一個path, 所以我們的 list 中只有一個元素 , 元素中包含一段path, 現在我們獲取到了描述path的矩陣PathMetric
PathMetric . length 就是這段path的長度了,唉,爲了找到你 ,我容易嗎 !
另外還有個關鍵的方法,可以將pathMetric按照給定的位置區間截取,最後返回這段path, 這就跟android中的PathMeasure.getSegment()是一樣
extractPath(double start, double end,{ bool startWithMoveTo:true }) → Path
給定起始和停止距離,返回中間段。
現在是時候將前面獲取到的當前動畫值 value 用起來了,找到當前path的length乘以value即是當前path的最新長度
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);
走到這裏,好像跨過了山和大海,得了,困死了,睡了、睡了...
現在曲線和折線都已經繪製完成了,不過剛開始的demo裏還有個漸變色的部分沒有完成,貌似有了漸變色以後,顯得不那麼單調了,其實,我們繪圖所用到的Paint還有一個屬性shader,可以繪製線條或區域的漸變色,LinearGradient可實現線性漸變的效果,默認爲從左到右繪製,你可以通過begin和end屬性自定義繪製的方向,我們這裏需要指定爲從上至下,並且顏色類型爲數組的形式,所以你可以傳入多個顏色值來繪製
var shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
tileMode: TileMode.clamp,
colors: shaderColors)
.createShader(Rect.fromLTRB(startX, endY, startX, startY));
值得注意的是,通過 createShader的方式創建shader,你需要指定繪製區域的邊界,我們這裏要實現的是從上至下,所以就以y軸爲基準,指定從上至下的繪製方向
既然是繪製漸變色,所以畫筆的樣式必須設置爲填充狀態
Paint shadowPaint = new Paint();
shadowPaint
..shader = shader
..isAntiAlias = true
..style = PaintingStyle.fill;
另外,漸變色的區域我們是通過path來指定上面的邊界的,所以我們還需要指定path下面部分的起點和終點,這樣形成一個閉環,才能確定出完整的區域
///從path的最後一個點連接起始點,形成一個閉環
shadowPath
..lineTo(startX + fixedWidth * value, startY)
..lineTo(startX, startY)
..close();
canvas..drawPath(shadowPath, shadowPaint);
至此,即可實現帶有漸變色的曲線或者折線,也許你有個疑問,畫折線爲什麼也要用path呢,不是可以直接drawLine嗎 ?機智如我,添加到path以後,可以更方便的繪製,添加動畫也很方便
另附上最終的實現效果,至於觸摸操作就不打算闡述了,可以參考以下代碼
代碼已發佈到 Dart社區 https://pub.dev/flutter/packages?q=flutter_chart