前言
前不久,利用週末時間學習並完成一個簡單的 Flutter 項目 - 簡悅天氣,簡約不簡單,豐富不復雜,這是一款簡約風格的 flutter 天氣項目,提供實時、多日、24 小時、颱風路徑以及生活指數等服務,支持定位、刪除、搜索等操作。
下圖爲主頁效果,可以 點擊這裏 進行下載 apk 體驗:
開始
本身作爲天氣 APP,自定義繪製自然少不了,首頁多樣的背景效果,炫酷的雨雪效果,展示當前空氣質量和體感的圓環效果,動態溫度折線圖和日出日落圖。
其實 pub.dev 上已經有不少 chart 插件,提供豐富的圖表類型,支持各種動畫和手勢。但是如果是像本項目,使用場景並不需要手勢,且沒有複雜的動畫,只存在折線這種形態,完全可以自己實現。一方面可以鞏固和拓展 flutter 的繪製相關知識點,另一方面根據自己的實際需求,可以擁有更多的定製化功能。
先看一下最終效果,其中包括:
-
動態降雨折線圖
-
多日折線圖
-
24小時折線圖
-
AQI圓弧
-
日出日落圖
繪製
接下來,會以上述效果作爲切入點,由簡到難,由靜態到動態,逐步分析繪製前數據的準備和繪製時相關接口調用,最後,總結出折線圖繪製的通用思路,對後續有相關需求的小夥伴提供幫助。
AQI圓弧
先從最簡單圓弧圖開始,如上圖可看到的信息有:半透明的圓弧,純白色的圓弧,居中的 AQI 值以及其底部的文字描述。對於此圖而言,只需要知道 ratio: 白色圓弧佔比、AQIValue 和 AQIDesc。
這個簡單直接先上代碼再分析。
@override
void paint(Canvas canvas, Size size) {
weatherPrint("AqiChartPainter size:$size");
var radius = size.height / 2 - 10;
var centerX = size.width / 2;
var centerY = size.height / 2;
var centerOffset = Offset(centerX, centerY);
// 繪製半透明圓弧
_path.reset();
_path.addArc(Rect.fromCircle(center: centerOffset, radius: radius),
pi * 0.7, pi * 1.6);
_paint.style = PaintingStyle.stroke;
_paint.strokeWidth = 4;
_paint.strokeCap = StrokeCap.round;
_paint.color = Colors.white38;
canvas.drawPath(_path, _paint);
// 繪製純白色圓弧
_path.reset();
_path.addArc(Rect.fromCircle(center: centerOffset, radius: radius),
pi * 0.7, pi * 1.6 * ratio);
_paint.color = Colors.white;
canvas.drawPath(_path, _paint);
// 繪製 AQIValue
var valuePara = UiUtils.getParagraph(value, 30);
canvas.drawParagraph(
valuePara,
Offset(centerOffset.dx - valuePara.width / 2,
centerOffset.dy - valuePara.height / 2));
// 繪製 AQIDesc
var descPara = UiUtils.getParagraph("$desc", 15);
canvas.drawParagraph(
descPara,
Offset(centerOffset.dx - valuePara.width / 2,
centerOffset.dy + valuePara.height / 2));
}
將步驟進行分解:
先繪製半透明圓弧,確認中心點座標和半徑,通過
_path.addArc(Rect oval, double startAngle, double sweepAngle)
方法進行繪製。oval: 圓弧所在矩形,startAngle: 起始角度(以鐘錶爲例,0爲3點方向),sweepAngle: 劃過角度(默認方向順時針)。在半透明圓弧基礎上,根據 ratio (
currentAqiValue / totalAqiValue
) 繪製純白色圓弧。-
依次繪製中間 AQIValue 和 AQIDesc。Flutter 繪製文本跟 Android 比起來略微有點麻煩,通過構造
ui.Paragraph
對象,然後調用canvas.drawParagraph(Paragraph paragraph, Offset offset)
方法進行繪製。一般通過封裝好的靜態初始化方法構建ui.Paragraph
對象:static ui.Paragraph getParagraph(String text, double textSize, {Color color = Colors.white, double itemWidth = 100}) { var pb = ui.ParagraphBuilder(ui.ParagraphStyle( textAlign: TextAlign.center, //居中 fontSize: textSize, //大小 )); pb.addText(text); pb.pushStyle(ui.TextStyle(color: color)); var paragraph = pb.build()..layout(ui.ParagraphConstraints(width: itemWidth)); return paragraph; }
關鍵詞: addArc
、Paragraph
、 drawParagraph
日出日落貝塞爾曲線
上圖看起來像是圓弧,其實是使用二階貝塞爾曲線進行繪製。圖中涵蓋的信息並不多,其中包括左右日出日落時間、整體虛曲線、動態實曲線和當前時間。對於需要的數據除了日出日落時間,還需要根據 (nowTime - sunriseTime)/(sunsetTime - sunriseTime)
獲取佔比 ratio。
繼續分解步驟:
-
繪製 虛曲線,首先確認起點和終點,通過
_path.quadraticBezierTo(double x1, double y1, double x2, double y2)
繪製貝塞爾曲線,參數需要傳入 控制點 座標和 終點 座標。很遺憾 Flutter 沒有提供虛線的接口,借用path_drawing
插件中的dashPath(Path source, {@required CircularIntervalList<double> dashArray,DashOffset dashOffset,})
方法進行虛線的繪製。var height = size.height; var width = size.width; double startX = marginLeftRight; double startY = height - marginBottom; double endX = width - marginLeftRight; double endY = startY; _path.reset(); _path.moveTo(startX, startY); _path.quadraticBezierTo(width / 2, marginTop, endX, endY); _paint.color = Colors.white; _paint.style = PaintingStyle.stroke; _paint.strokeWidth = 1.5; canvas.drawPath( dashPath(_path, dashArray: CircularIntervalList<double>([10, 5])), _paint);
-
繪製 實虛線,這裏遇到一個問題,已知比例 ratio,在虛曲線上繪製實曲線(保證重疊),不同於直線或者弧線,通過控制 xy 或者 sweepAngle 輕鬆實現。對二階貝塞爾曲線稍有了解的可以知道,其主要由起始點和控制點組成,這三個值稍有變化,都很難做到重疊,所以得另闢蹊徑。
Android 中有 PathMeasure 可以對 Path 進行分段,然後根據需要繪製的段數進行控制。同樣,Flutter 也有對應的 API:
var metrics = _path.computeMetrics(); var pm = metrics.elementAt(0); Offset sunOffset = pm.getTangentForOffset(pm.length * ratio).position; canvas.save(); canvas.clipRect(Rect.fromLTWH(0, 0, sunOffset.dx, height)); canvas.drawPath(_path, _paint); canvas.restore();
通過 getTangentForOffset 得到 ratio 下在曲線上的 x,y 座標點,然後
_path.clipRect()
對虛曲線裁剪最終得到實曲線。 -
繪製小太陽和當前時間,知道曲線上的 x,y 座標,這就好辦了
_paint.style = PaintingStyle.fill; _paint.color = Colors.yellow; canvas.drawCircle(sunOffset, 6, _paint); var now = DateTime.now(); String nowTimeStr = "${now.hour}:${now.minute}"; var nowTimePara = UiUtils.getParagraph(nowTimeStr, 14); canvas.drawParagraph(nowTimePara, Offset(sunOffset.dx - nowTimePara.width / 2, sunOffset.dy + 10));
關鍵詞:
quadraticBezierTo
、dashPath
、computeMetrics
、getTangentForOffset
、clipRect
、drawCircle
多日折線圖
-
多日折線圖
上下的文字區域繪製根據各自高度順延繪製即可,只要預留出中間折線的繪製區域即可。中間的折線區域又可以繼續平分成 top 和 bottom 兩個折線,各自繪製各自的,互不干擾。
折線圖的繪製思路分爲三步:找出最大最小值、計算單位溫度的 y 值和遍歷繪製
-
遍歷找出 top 和 bottom 的最大最小值
void setMinMax() { _data.forEach((element) { if (element.dayTemp > topMaxTemp) { topMaxTemp = element.dayTemp; } if (element.dayTemp < topMinTemp) { topMinTemp = element.dayTemp; } if (element.nightTemp > bottomMaxTemp) { bottomMaxTemp = element.nightTemp; } if (element.nightTemp < bottomMinTemp) { bottomMinTemp = element.nightTemp; } }); }
-
根據溫度計算x,y值,目前已知折線的高度 itemHeight, 具體溫度 temp,起點 topLineStartY,最高最低溫度已經實際溫度,即可算出溫度對應的 y 座標值,x座標值
getTopLineY(int temp) { if (temp == topMaxTemp) { return topLineStartY; } return topLineStartY + (topMaxTemp - temp) / (topMaxTemp - topMinTemp) * lineHeight; } x = startX + index*itemWidth;
-
開始繪製,x,y 都知道了,直線、原點以及文字都可以進行遍歷繪製了
_paint.color = Colors.white; var topOffset = Offset(startX, getTopLineY(element.dayTemp)); var bottomOffset = Offset(startX, getBottomLineY(element.dayTemp)); _paint.style = PaintingStyle.fill; // 繪製折線上的圓點 canvas.drawCircle(topOffset, 3, _paint); canvas.drawCircle(bottomOffset, 3, _paint); // 繪製圓點上下的溫度值 var topTempPara = UiUtils.getParagraph("${element.dayTemp}°", mainTextSize, itemWidth: itemWith); canvas.drawParagraph( topTempPara, Offset(topOffset.dx - topTempPara.width / 2, topOffset.dy - topTempPara.height - 5)); var bottomTempPara = UiUtils.getParagraph("${element.dayTemp}°", mainTextSize, itemWidth: itemWith); canvas.drawParagraph( bottomTempPara, Offset(bottomOffset.dx - bottomTempPara.width / 2, bottomOffset.dy + 5)); // 繪製折線 if (index == 0) { _topPath.moveTo(topOffset.dx, topOffset.dy); _bottomPath.moveTo(bottomOffset.dx, bottomOffset.dy); } else { _topPath.lineTo(topOffset.dx, topOffset.dy); _bottomPath.lineTo(bottomOffset.dx, bottomOffset.dy); } startX += itemWith; }); _paint.strokeWidth = 2; _paint.style = PaintingStyle.stroke; canvas.drawPath(_topPath, _paint); canvas.drawPath(_bottomPath, _paint); }
關鍵詞: 最大最小值
動態降雨折線圖
終於到了今天最難的角登場,只是對比前幾個比較難,在上述折線的基礎上加了折線入場動畫。話不多說咱們開始吧,上圖可拆成三部分,背景(y軸,xy軸描述)、漸變折線和動畫
背景
x 軸被二等分,y 軸被三等分,計算出 xItemWidth 和 yItemHeight,然後繪製線和文字
void drawBg(Canvas canvas, Size size) {
// 繪製背景 line
double itemHeight = (size.height - _marginBottom) / 3;
double bgLineWidth = size.width - _marginLeft - _marginRight;
_paint.style = PaintingStyle.stroke;
_paint.strokeWidth = 1;
_paint.color = Colors.white.withAlpha(100);
for (int i = 0; i < 4; i++) {
var startOffset = Offset(_marginLeft, itemHeight * i);
var endOffset = Offset(_marginLeft + bgLineWidth, itemHeight * i);
canvas.drawLine(startOffset, endOffset, _paint);
}
// 繪製底部文字
var hourY = size.height - _marginBottom + _timeMarginTop;
var nowPara = UiUtils.getParagraph("現在", _textSize, itemWidth: bgLineWidth / 3);
canvas.drawParagraph(nowPara, Offset(_marginLeft - nowPara.width / 2, hourY));
var onePara = UiUtils.getParagraph("1小時後", _textSize, itemWidth: bgLineWidth / 3);
canvas.drawParagraph(onePara, Offset(_marginLeft + bgLineWidth / 2 - onePara.width / 2, hourY));
var twoPara = UiUtils.getParagraph("2小時後", _textSize, itemWidth: bgLineWidth / 3);
canvas.drawParagraph(twoPara, Offset(_marginLeft + bgLineWidth - twoPara.width / 2, hourY));
// 繪製左側文字
var bigPara = UiUtils.getParagraph("大", _textSize);
canvas.drawParagraph(bigPara, Offset(_marginLeft / 2 - bigPara.width / 2, 0));
var middlePara = UiUtils.getParagraph("中", _textSize);
canvas.drawParagraph(middlePara, Offset(_marginLeft / 2 - middlePara.width / 2, itemHeight));
var smallPara = UiUtils.getParagraph("小", _textSize);
canvas.drawParagraph(smallPara, Offset(_marginLeft / 2 - smallPara.width / 2, itemHeight * 2));
}
漸變折線
-
繪製折線,最大值不用計算已經知道 yMax = 1.0,xMax = 120,可以計算出點的 x,y 座標值,然後進行遍歷繪製
double width = size.width - _marginLeft - _marginRight; double height = size.height - _marginBottom; double startX = _marginLeft; double itemWidth = width / 120; double itemHeight = height / 100; _linePath.reset(); for (int i = 0; i < _data.length; i++) { double y = height - _data[i] * 100 * itemHeight * _ratio; double x = startX + i * itemWidth; if (i == 0) { _linePath.moveTo(x, y); } else { _linePath.lineTo(x, y); } } _linePaint.style = PaintingStyle.stroke; _linePaint.strokeWidth = 1; _linePaint.color = Colors.white; canvas.drawPath(_linePath, _linePaint); _linePath.lineTo(width + startX, height); _linePath.lineTo(startX, height); _linePath.close();
-
漸變效果,複用折線 path,通過
ui.Gradient.linear
創建漸變區域,然後設置到_linePaint.shader
上var gradient = ui.Gradient.linear( Offset(0, 0), Offset(0, height), <Color>[ const Color(0xFFffffff), const Color(0x00FFFFFF) ], ); _linePaint.style = PaintingStyle.fill; _linePaint.shader = gradient; canvas.drawPath(_linePath, _linePaint);
入場動畫
在 漸變折線#1 中對 y 的計算 double y = height - _data[i] * 100 * itemHeight * _ratio;
中提到了 _ratio,這個就是控制動畫效果關鍵變量,區間 [0,1],0爲y=0.0
的直線,1爲實際的折線圖效果。
而這個 _ratio 有動畫進行控制:
_controller =
AnimationController(duration: Duration(milliseconds: 250), vsync: this);
CurvedAnimation(parent: _controller, curve: Curves.linear);
_controller.addListener(() {
setState(() {
_ratio = _controller.value;
});
});
最終的動態折線效果即可完成。
關鍵詞:drawLine
、ui.Gradient.linear
、AnimationController
總結
整體下來,無論是圓弧、曲線還是折線或者類似簡單的繪製都有章可循。
- 對 待實現效果進行分析,找出關鍵信息進行分層分步,找出靜態數據和動態數據,也就是常量和變量。
- 計算好基礎數據,比如整體寬高,單位寬高,起始值,最大最小值
- 有了數據支撐,根據效果調用對應的繪製 API,設置 paint 的相關屬性,完成繪製
- 如果有動畫,以控制變量作爲切入口,動畫本身只關注變量值的改變,而不用考慮變量對繪製的影響