轉載請標明出處: juejin.im/post/5b533f…
本文出自:Wos的主頁node
它具體包括如下功能:git
看不清楚? 不過癮? 下載 APK 親自體驗 Flutter 的流暢與強大github
雖然本文定位爲進階內容, 但實際若是你們對Canvas稍有了解, 仍是比較容易理解的. 我也但願本身可以詳盡/直白將我思路講述清楚.算法
本項目是基於Android環境實現的, 可是... 代碼徹底使用Flutter(Dart)實現, 所以也能夠完美運行在iOS設備上.json
注意: 爲了方便閱讀, 本文中的代碼和我在Github上的代碼略有出入canvas
本文內容來源於我在Flutter學習過程當中的理解和實踐, 不能做爲最佳實踐. 若有不妥之處但願你們指出, 謝謝.數組
這篇文章的篇幅較長, 主要是我將帶領你們一步步的實現這樣的一個海拔圖控件. 雖然不是詳盡到每一步的代碼都貼出來, 但也是擁有大量內容.bash
技術較強大佬或不想看這麼多的內容, 能夠直接去看個人源碼, 若有疑問能夠回到本文搜索對應的解釋, 或在下方評論留言.服務器
除此以外, 建議你們創建一個新的項目, 跟着我一步一步動手把它實現出來.markdown
lib
包下創建一個新的dart文件:altitude_graph
咱們的主要工做都將在這個文件中完成.在這個文件中, 咱們先創建一個初始的StatefulWidget
: AltitudeGraphView
.
而後咱們在State
的build方法中返回一個基本的架構. 以下:
return Column(
mainAxisSize: MainAxisSize.max,
children: <Widget>[
// 主視圖
Expanded(
child: SizedBox.expand(
child: GestureDetector(
child: CustomPaint(
painter: AltitudePainter(),
),
),
),
),
// 底部控制Bar
Container(
width: double.infinity,
height: 48.0,
color: Colors.lightGreen,
),
],
);
複製代碼
mainAxisSize: MainAxisSize.max
是爲了讓Column佔滿父控件
SizedBox.expand
是爲了讓其子控件GestureDetector
佔滿Column的剩餘空間
AltitudePainter
是咱們繪製圖表的地方, 咱們先建立一個最初的模板在文件下面空白處, 新建一個class AltitudePainter extends CustomPainter
實現方法並修改成以下:
class AltitudePainter extends CustomPainter{
Paint linePaint = Paint()..color = Colors.red;
@override
void paint(Canvas canvas, Size size) {
canvas.drawRect(Rect.fromLTWH(0.0, 0.0, size.width, size.height), linePaint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return true;
}
}
複製代碼
bool shouldRepaint(CustomPainter oldDelegate)
告知系統是否須要重繪. 咱們暫時先給它返回一個ture
表示一直重繪.
void paint(Canvas canvas, Size size)
當繪製時回調此方法.
上面的代碼中, 咱們已經建立了一個簡單的Paint
對象並設置了一個顏色, 而後使用canvas
繪製了一個和所給的Size
同樣大小的矩形.
咱們在altitude_graph
文件的空白處添加一個數據模型類, 它具體以下:
const Color kLabelTextColor = Colors.white;
class AltitudePoint {
/// 當前點的名字, 例如: xx鎮
String name;
/// 當前點的級別, 用於根據縮放級別展現不一樣的地標標籤.
int level;
/// `point.x`表示當前點距離上一個點的距離. `point.y`表示當前點的海拔
Offset point;
/// 地標標籤的背景色
Color color;
/// 用於繪製文字, 存在這裏是爲了不每次繪製重複建立.
TextPainter textPainter;
AltitudePoint(this.name, this.level, this.point, this.color, {this.textPainter}) {
if (name == null || name.isEmpty || textPainter != null) return;
// 向String插入換行符使文字豎向繪製
var splitMapJoin = name.splitMapJoin('', onNonMatch: (m) {
return m.isNotEmpty ? "$m\n" : "";
});
splitMapJoin = splitMapJoin.substring(0, splitMapJoin.length - 1);
this.textPainter = TextPainter(
textDirection: TextDirection.ltr,
text: TextSpan(
text: splitMapJoin,
style: TextStyle(
color: kLabelTextColor,
fontSize: 8.0,
),
),
)..layout();
}
}
複製代碼
後面咱們將要繪製的海拔圖, 就是由成百上千個這樣點數據組成的
level
這個屬性後面會具體講解
TextPainter
的開銷是很是大的, 應當避免在繪製時建立, 尤爲應該避免重複建立. 所以咱們在數據建立時就把它們建立出來.
來到建立項目時自動生成的main.dart
文件中, 將無用的代碼及註釋刪除掉.
而後將Scaffold
的body
換成咱們的AltitudeGraphView()
, 根據提示進行導包
如今, 讓咱們看看運行效果
能夠看到, 上下已經被分爲了兩個區域.
咱們想把真實的海拔數據畫到圖上, 首先須要一個海拔資源文件
海拔數據能夠點擊這裏下載(如沒有彈出下載,右鍵點擊網頁選擇"存儲爲").
在項目的根目錄下建立資源文件夾assets/raw
將 json 文件放到裏面
接下來打開pubspec.yaml
文件. 在flutter:
下注冊資源文件. 以下:
flutter:
assets:
- assets/raw/CHUANZANGNAN.json
複製代碼
yaml語法是強格式化的, 必定要注意空格
這個 json 文件中存的是一個完整的路線信息, 包括海拔等其它不少信息.
咱們只須要一部分繪製所需的信息, 所以咱們來建立一個數據提供者. 負責加載資源文件並將其轉換爲AltitudePoint
數據集合.
在lib
包下再新建一個dart文件:altitude_point_data
, 而後添加代碼以下:
import 'dart:async';
import 'dart:convert';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter/material.dart';
import 'package:flutter_altitude_graph/altitude_graph.dart';
const Color START_AND_END = Colors.red;
const Color CITY = Colors.deepOrange;
const Color COUNTY = Colors.blueGrey;
const Color TOWN = Colors.blue;
const Color VILLAGE = Colors.green;
const Color MOUNTAIN = Colors.brown;
const Color TUNNEL = Colors.red;
const Color CAMP_SPOT = Colors.blue;
const Color SCENIC_SPOT = Colors.blueGrey;
const Color CHECK_POINT = Colors.orange;
const Color BRIDGE = Colors.green;
const Color GAS_STATION = Colors.lightGreen;
const Color OTHERS = Colors.deepPurpleAccent;
Future<List<AltitudePoint>> parseGeographyData(String assetPath) {
return rootBundle
.loadString(assetPath, cache: false)
.then((fileContents) => json.decode(fileContents))
.then((jsonData) {
List<AltitudePoint> list = List();
var arrays = jsonData["RECORDS"];
double mileage = 0.0;
for (var geo in arrays) {
var name = geo["NAME"];
if (name.contains('_')) name = null; // 低級別地名不顯示
int level;
Color color;
var altitude = double.parse(geo["ELEVATION"]);
/// 根據不一樣的type定義各個點的級別和label的顏色, 這將影響到在不一樣的縮放級別下, 顯示哪些label
/// level值越大, 優先級越高
switch (geo["TYPES"]) {
case 'CITY':
level = 4;
color = CITY;
break;
case 'MOUNTAIN':
level = 3;
color = MOUNTAIN;
break;
case 'COUNTY':
level = 3;
color = COUNTY;
break;
case 'TOWN':
level = 2;
color = TOWN;
break;
case 'VILLAGE':
level = 2;
color = VILLAGE;
break;
case 'TUNNEL':
level = 2;
color = TUNNEL;
break;
case 'BRIDGE':
level = 2;
color = BRIDGE;
break;
case 'CHECK_POINT':
level = 1;
color = CHECK_POINT;
break;
case 'CAMP_SPOT':
level = 1;
color = CAMP_SPOT;
break;
case 'SCENIC_SPOT':
level = 1;
color = SCENIC_SPOT;
break;
default:
level = 0;
color = OTHERS;
break;
}
var altitudePoint = new AltitudePoint(
name,
level,
Offset(mileage, altitude),
color,
);
list.add(altitudePoint);
/// 累加里程
/// 原始Json中的distance表示的是當前點距離下一個點的距離, 可是咱們這裏須要計算的是[當前點距離起點的距離]
/// 例如: 第一個點就是起點所以距離起點是0千米, 第一個點距離第二個點2千米, 所以第二個點距離起點2千米
/// 第二個點距離第三個點3千米, 所以第三個點距離起點是5千米, 以此類推...
double distance = double.parse(geo["F_DISTANCE"]);
mileage = mileage + distance;
}
list.first.level = 5;
list.first.color = START_AND_END;
list.last.level = 5;
list.last.color = START_AND_END;
return list;
});
}
複製代碼
這段代碼的parseGeographyData
方法中, 咱們經過 rootBundle
提供的方法將 assetPath
以字符流形式讀取爲一個字符串, 並生成了一個Json對象.
接下來咱們從Json對象中取到海拔路徑的Json數組, 並在循環中依次解析出咱們所需的數據, 最終生成一個個 AltitudePoint
對象添加到集合中.
在這段代碼中, 佔篇幅比較大的地方在於 根據海拔路徑的點的 TYPES
給這個點設置 level
, 而且不一樣的level
對應不一樣的標籤背景色.
第二步只是爲了給海拔圖控件提供數據, 並非海拔圖控件必要組成部分. 海拔圖只關心數據自己而不關心數據從何而來, 也所以, 這裏關於level
和標籤背景color
的設置實際上是比較隨意的.
altutide_graph.dart
文件, 添加所需的顏色常量const Color kAxisTextColor = Colors.black;
const Color kVerticalAxisDottedLineColor = Colors.amber;
const Color kAltitudeThumbnailPathColor = Colors.grey;
const Color kAltitudeThumbnailGradualColor = Color(0xFFE0EFFB);
const Color kAltitudePathColor = Color(0xFF003c60);
const List<Color> kAltitudeGradientColors = [Color(0x821E88E5), Color(0x0C1E88E5)];
複製代碼
AltitudeGraphView
添加屬性及構造final List<AltitudePoint> altitudePointList;
AltitudeGraphView(this.altitudePointList);
複製代碼
AltitudePainter
中的測試內容, 添加如下屬性及構造// ===== Data
/// 海拔數據集合
List<AltitudePoint> _altitudePointList;
/// 最高海拔
double _maxAltitude = 0.0;
/// 最低海拔
double _minAltitude = 0.0;
/// 縱軸最大值
double _maxVerticalAxisValue;
/// 縱軸最小值
double _minVerticalAxisValue;
/// 縱軸點與點之間的間隔
double _verticalAxisInterval;
// ===== Paint
/// 海拔線的畫筆
Paint _linePaint;
/// 海拔線填充的畫筆
Paint _gradualPaint;
/// 關鍵點的畫筆
Paint _signPointPaint;
/// 縱軸水平虛線的畫筆
Paint _levelLinePaint;
/// 文字顏色
Color axisTextColor;
/// 海拔線填充的梯度顏色
List<Color> gradientColors;
AltitudePainter(
this._altitudePointList,
this._maxAltitude,
this._minAltitude,
this._maxVerticalAxisValue,
this._minVerticalAxisValue,
this._verticalAxisInterval, {
this.axisTextColor = kAxisTextColor,
this.gradientColors = kAltitudeGradientColors,
Color pathColor = kAltitudePathColor,
Color axisLineColor = kVerticalAxisDottedLineColor,
}) : _linePaint = Paint()
..strokeWidth = 1.0
..style = PaintingStyle.stroke
..color = pathColor,
_gradualPaint = Paint()
..isAntiAlias = false
..style = PaintingStyle.fill,
_signPointPaint = Paint(),
_levelLinePaint = Paint()
..strokeWidth = 1.0
..isAntiAlias = false
..color = axisLineColor
..style = PaintingStyle.stroke;
複製代碼
在上面的代碼中, 咱們建立了接下來繪製所須要的部分屬性. 主要是海拔數據, 繪製縱軸所須要的數據 以及繪製所需的全部Paint
在上面的步驟中, AltitudePainter
構造須要一些必要參數. _AltitudeGraphViewState
的build
中也會報紅線提示咱們.
爲_AltitudeGraphViewState
添加如下屬性
// ==== 海拔數據
double _maxAltitude = 0.0;
double _minAltitude = 0.0;
double _maxVerticalAxisValue = 0.0;
double _minVerticalAxisValue = 0.0;
double _verticalAxisInterval = 0.0;
複製代碼
添加如下方法, 計算海拔圖數據
/// 遍歷數據, 取得 最高海拔值, 最低海拔值, 最高Level, 最低Level.
/// 根據最高海拔值和最低海拔值計算出縱軸最大值和最小值.
_initData() {
if (widget.altitudePointList?.isEmpty ?? true) return;
var firstPoint = widget.altitudePointList.first.point;
_maxAltitude = firstPoint.dy;
_minAltitude = firstPoint.dy;
for (AltitudePoint p in widget.altitudePointList) {
if (p.point.dy > _maxAltitude) {
_maxAltitude = p.point.dy;
} else if (p.point.dy < _minAltitude) {
_minAltitude = p.point.dy;
}
}
var maxDivide = _maxAltitude - _minAltitude;
if (maxDivide > 1000) {
_maxVerticalAxisValue = (_maxAltitude / 1000.0).ceil() * 1000.0;
_minVerticalAxisValue = (_minAltitude / 1000.0).floor() * 1000.0;
} else if (maxDivide > 100) {
_maxVerticalAxisValue = (_maxAltitude / 100.0).ceil() * 100.0;
_minVerticalAxisValue = (_minAltitude / 100.0).floor() * 100.0;
} else if (maxDivide > 10) {
_maxVerticalAxisValue = (_maxAltitude / 10.0).ceil() * 10.0;
_minVerticalAxisValue = (_minAltitude / 10.0).floor() * 10.0;
}
_verticalAxisInterval = (_maxVerticalAxisValue - _minVerticalAxisValue) / 5;
var absVerticalAxisInterval = _verticalAxisInterval.abs();
if (absVerticalAxisInterval > 1000) {
_verticalAxisInterval = (_verticalAxisInterval / 1000.0).floor() * 1000.0;
} else if (absVerticalAxisInterval > 100) {
_verticalAxisInterval = (_verticalAxisInterval / 100.0).floor() * 100.0;
} else if (absVerticalAxisInterval > 10) {
_verticalAxisInterval = (_verticalAxisInterval / 10.0).floor() * 10.0;
}
}
複製代碼
在這個方法中, 咱們首先遍歷了widget
中的altitudePointList
取得這個海拔路徑中的最高海拔和最低海拔.
接下來咱們根據最高海拔和最低海拔計算出了縱軸所須要顯示的縱軸最大值和縱軸最小值.
縱軸顯示的節點應該知足如下三個條件:
爲了知足上述三個條件, 咱們不能單純的以最高海拔和最低海拔做爲縱軸最大值和縱軸最小值.
上面代碼中, 我用了一種看着比較笨的方法, 對值進行了處理. 若是有更好的算法, 請不吝賜教
得出縱軸最大值和縱軸最小值後, 咱們再根據這兩個值計算出計算出縱軸上每一個節點間的間距. 也是須要給處理成一個"規整的數"
最後在initState()
和didUpdateWidget(AltitudeGraphView oldWidget)
生命週期方法內調用該_initData()
.
回到main.dart
文件
因爲剛剛咱們在AltitudePoint
中建立了一個構造方法並要求調用者傳遞一個必要參數, 所以如今main.dart
內應該有了一個報紅
咱們在_MyHomePageState
中添加一個成員變量List<AltitudePoint> _altitudePointList;
而後將其賦值給AltitudeGraphView
的構造
接下來咱們建立一個方法, 從資源文件中獲取海拔數據:
_loadData() {
parseGeographyData('assets/raw/CHUANZANGNAN.json').then((list) {
setState(() {
_altitudePointList = list;
});
});
}
複製代碼
而後咱們在_MyHomePageState
的initState
這個生命週期方法內調用_loadData()
首先添加以下代碼到AltitudePainter
的paint
方法
@override
void paint(Canvas canvas, Size size) {
// 30 是給上下留出的距離, 這樣豎軸的最頂端的字就不會被截斷, 下方能夠用來顯示橫軸的字
Size availableSize = Size(size.width, size.height - 30);
// 向下滾動15的距離給頂部留出空間
canvas.translate(0.0, 15.0);
// 繪製豎軸
_drawVerticalAxis(canvas, availableSize);
}
複製代碼
這段代碼中, 參數size
是AltitudePainter
的可繪製大小. 咱們不直接就用這個尺寸來繪製, 而是建立一個availableSize
做爲主繪製區域, 並經過canvas.translate()
將佈局向下滾動使繪製區域居中.
緣由是接下來的繪製中, 咱們不但願咱們要繪製的內容緊貼着控件的邊緣, 那樣會致使最上面及最下面的虛線和字緊貼着控件的邊緣, 甚至文字被截斷.
接下來實現_drawVerticalAxis(canvas, availableSize)
方法
void _drawVerticalAxis(Canvas canvas, Size size) {
var nodeCount = (_maxVerticalAxisValue - _minVerticalAxisValue) / _verticalAxisInterval;
var interval = size.height / nodeCount;
canvas.save();
for (int i = 0; i <= nodeCount; i++) {
var label = (_maxVerticalAxisValue - (_verticalAxisInterval * i)).toInt();
drawVerticalAxisLine(canvas, size, label.toString(), i * interval);
}
canvas.restore();
}
複製代碼
這段代碼中, 首先根據最大值 - 最小值得出有效值再 / 間隔 獲得 節點的數量. 例如: _maxVerticalAxisValue
=3500,_minVerticalAxisValue
=3000,_verticalAxisInterval
爲100,則nodeCount
=5
而後用繪製區域的高度 / 除以節點數量得出在屏幕上每一個節點之間的間隔
接下來一個for循環依次繪製個縱軸節點
須要注意i <= nodeCount
. 之因此用<=
是爲了 不管繪製幾個節點, 都會繪製最下面的一個節點.
實現_drawVerticalAxisLine(Canvas canvas, Size size, String text, double height)
繪製單個縱軸節點
/// 繪製數軸的一行
void _drawVerticalAxisLine(Canvas canvas, Size size, String text, double height) {
var tp = _newVerticalAxisTextPainter(text)..layout();
// 繪製虛線
// 虛線的寬度 = 可用寬度 - 文字寬度 - 文字寬度的左右邊距
var dottedLineWidth = size.width - 25.0;
canvas.drawPath(_newDottedLine(dottedLineWidth, height, 2.0, 2.0), _levelLinePaint);
// 繪製虛線右邊的Text
// Text的繪製起始點 = 可用寬度 - 文字寬度 - 左邊距
var textLeft = size.width - tp.width - 3;
tp.paint(canvas, Offset(textLeft, height - tp.height / 2));
}
/// 生成虛線的Path
Path _newDottedLine(double width, double y, double cutWidth, double interval) {
var path = Path();
var d = width / (cutWidth + interval);
path.moveTo(0.0, y);
for (int i = 0; i < d; i++) {
path.relativeLineTo(cutWidth, 0.0);
path.relativeMoveTo(interval, 0.0);
}
return path;
}
TextPainter textPainter = TextPainter(
textDirection: TextDirection.ltr,
maxLines: 1,
);
/// 生成縱軸文字的TextPainter
TextPainter _newVerticalAxisTextPainter(String text) {
return textPainter
..text = TextSpan(
text: text,
style: TextStyle(
color: axisTextColor,
fontSize: 8.0,
),
);
}
複製代碼
因爲我沒有找到在Flutter下畫虛線的方法, 因此用N個小段拼起來造成一條虛線.
前面說過TextPainter
的開銷比較大, 因此這裏只建立一個做爲成員變量
但實際上我並不知道TextPainter
的開銷來源於哪裏(猜想是layout()
方法), 通過我沒那麼嚴謹的測試, 把一個TextPainter
對象做爲成員變量, 和每次調用_newVerticalAxisTextPainter(String text)
都從新建立一個其實並無什麼區別. 若是有大佬知道請不吝賜教.
ok, 到這裏, 縱軸就繪製好了. 如今能夠運行起來看一看效果啦, 下一步, 咱們將爲海拔圖繪製折線.
在AltitudePainter
方法中paint
加入如下代碼
// 50 是給左右留出間距, 避免標籤上的文字被截斷, 同時避免線圖覆蓋豎軸的字
Size pathSize = Size(availableSize.width - 50, availableSize.height);
// 繪製線圖
canvas.save();
// 剪裁繪製的窗口, 節省繪製的開銷. -24 是爲了不覆蓋縱軸
canvas.clipRect(Rect.fromPoints(Offset.zero, Offset(size.width - 24, size.height)));
// _offset.dx一般都是向左偏移的量 +15 是爲了不關鍵點 Label 的文字被截斷
canvas.translate(15.0, 0.0);
_drawLines(canvas, pathSize);
canvas.restore();
複製代碼
接下來具體的來實現_drawLines(Canvas canvas, Size size)
/// 繪製海拔圖連線部分
/// 繪製海拔圖連線部分
void _drawLines(Canvas canvas, Size size) {
var pointList = _altitudePointList;
if (pointList == null || pointList.isEmpty) return;
double ratioX = size.width / pointList.last.point.dx;
double ratioY = (_maxVerticalAxisValue - _minVerticalAxisValue);
var path = Path();
var calculateDy = (double dy) {
return size.height - (dy - _minVerticalAxisValue) * ratioY;
};
var firstPoint = pointList.first.point;
path.moveTo(firstPoint.dx * ratioX, calculateDy(firstPoint.dy));
for (var p in pointList) {
path.lineTo(p.point.dx * ratioX, calculateDy(p.point.dy));
}
// 繪製線條下面的漸變部分
double gradientTop = size.height - ratioY * (_maxAltitude - _minVerticalAxisValue);
_gradualPaint.shader = ui.Gradient.linear(Offset(0.0, gradientTop), Offset(0.0, size.height), gradientColors);
_drawGradualShadow(path, size, canvas);
// 先繪製漸變再繪製線,避免線被遮擋住
canvas.save();
canvas.drawPath(path, _linePaint);
canvas.restore();
}
複製代碼
上面代碼須要導包 import 'dart:ui' as ui;
首先計算出海拔圖映射到屏幕上的比例, 例如終點是2000千米, 映射到400(理論像素)寬的屏幕上ratioX
就是0.2. 同理最大海拔差爲1000映射到500高的屏幕上時ratioY
就是0.2
接下來咱們聲明瞭一個Path
對象, 用於存儲接下來要繪製的折線的路徑信息
而後是一個用來計算y軸繪製點的內部方法calculateDy
. 如下是該方法的分步講解:
(dy - _minVerticalAxisValue)
這段代碼中dy
是海拔的高度, 海拔以0爲起始點, 而咱們在繪製時是以_minVerticalAxisValue
做爲起始點的, 所以須要相減獲得相對海拔高度.* ratioY
獲得海拔映射到屏幕的高度size.height -
海拔映射到屏幕的高度 是由於繪製的座標y軸向下爲正數, 海拔越高越處於屏幕向下的位置, 所以須要用size.height
相減使海拔越高越處於屏幕向上的位置.接下來調用path.moveTo
將畫筆的起始位置挪到第一個座標點. 而後經過for
循環將全部的海拔路徑點都映射爲屏幕上的座標點.
獲得路徑數據後, 先不着急繪製折線, 而是先繪製咱們效果圖中看到的折線下面的漸變投影.
爲此, 咱們須要先實現_drawGradualShadow(path, size, canvas)
方法
void _drawGradualShadow(Path path, Size size, Canvas canvas) {
var gradualPath = Path.from(path);
gradualPath.lineTo(gradualPath.getBounds().width, size.height);
gradualPath.relativeLineTo(-gradualPath.getBounds().width, 0.0);
canvas.drawPath(gradualPath, _gradualPaint);
}
複製代碼
回到上面, 咱們首先須要給漸變設定一個範圍, 範圍影響到漸變的效果. 因爲咱們的漸變是由上至下的, 所以漸變的範圍只須要考慮y軸, 不須要考慮x軸. 最終咱們的y軸範圍=從最高海拔映射到屏幕上的y軸座標點到繪製區域的最底端
而後在_drawGradualShadow
方法中, 咱們經過剛纔生成的Path
生成一個新的Path
. 接下來的gradualPath.lineTo
和gradualPath.relativeLineTo
是爲了使gradualPath
閉合起來(這裏省了一步,但會自動閉合起來).
最後, 繪製完漸變投影后,
完成這一步, 讓咱們運行起來看看效果吧.
在AltitudePainter
的paint
中添加如下代碼:
// 高度 +2 是爲了將橫軸文字置於底部並加一個 marginTop.
double hAxisTransY = availableSize.height + 2;
canvas.save();
// 剪裁繪製窗口, 減小繪製時的開銷.
canvas.clipRect(Rect.fromPoints(Offset(0.0, hAxisTransY), Offset(size.width, size.height)));
// x偏移和線圖對應上, y偏移將繪製點挪到底部
canvas.translate(15.0, hAxisTransY);
_drawHorizontalAxis(canvas, availableSize.width, pathSize.width);
canvas.restore();
複製代碼
首先計算了一下橫軸的繪製區域相對於視圖頂部的間距.
接着咱們剪裁了繪製區域, 而後向右下偏移, 使繪製的起始點和折線對齊且和上方保持一點點間距
而後調用_drawHorizontalAxis
方法進行具體的繪製.
這裏咱們將控件的寬度(_drawHorizontalAxis
)以及折線部分的繪製區域的寬度(pathSize.width
)傳遞給該方法.
接下來咱們來實現_drawHorizontalAxis(Canvas canvas, double viewportWidth, double totalWidth)
void _drawHorizontalAxis(Canvas canvas, double viewportWidth, double totalWidth) {
Offset lastPoint = _altitudePointList?.last?.point;
if (lastPoint == null) return;
double ratio = viewportWidth / totalWidth;
double intervalAtDistance = lastPoint.dx * ratio / 6.0;
int intervalAtHAxis;
if (intervalAtDistance >= 100.0) {
intervalAtHAxis = (intervalAtDistance / 100.0).ceil() * 100;
} else if (intervalAtDistance >= 10) {
intervalAtHAxis = (intervalAtDistance / 10.0).ceil() * 10;
} else {
intervalAtHAxis = (intervalAtDistance / 5.0).ceil() * 5;
}
double hAxisIntervalScale = intervalAtHAxis.toDouble() / intervalAtDistance;
double intervalAtScreen = viewportWidth / 6.0 * hAxisIntervalScale;
double count = totalWidth / intervalAtScreen;
for (int i = 0; i <= count; i++) {
_drawHorizontalAxisLine(
canvas,
"${i * intervalAtHAxis}",
i * intervalAtScreen,
);
}
}
複製代碼
viewportWidth
參數是爲了計算橫軸的每一個節點在屏幕上的跨距是多少. totalWidth
是折線部分的寬度也是橫軸的總寬度, 用於計算橫軸上節點的數量
第一步咱們計算出總寬度映射到控件寬度上的比例
而後咱們用這個比例和終點相乘獲得縮放後的大小, 後面的 6.0
是橫軸在屏幕上最多同時顯示6個節點, 想設置爲幾都行
如今獲得的intervalAtDistance
是一個不規整
的數, 咱們也像處理縱軸的節點同樣, 將其變成規整
的intervalAtHAxis
. 這一步使得 假設intervalAtDistance
爲100+ ~ 200 則都顯示爲 200.
hAxisIntervalScale
表示一個縮放比. 例如, 雖然 101 和200 都顯示爲200, 可是它們在屏幕上的跨距是不同的. 用這個縮放比和節點在屏幕上的跨距(viewportWidth / 6.0
)相乘獲得最終的節點在屏幕上的跨距
接下來, 經過totalWidth / intervalAtScreen
獲得橫軸上的總節點數量. 而後進行for
循環, 依次將橫軸上的每個節點繪製出來
接下來咱們來實現_drawHorizontalAxisLine(Canvas canvas, String text, double width)
/// 繪製數軸的一行
void _drawHorizontalAxisLine(Canvas canvas, String text, double width) {
var tp = _newVerticalAxisTextPainter(text)..layout();
var textLeft = width + tp.width / -2;
tp.paint(canvas, Offset(textLeft, 0.0));
}
複製代碼
這一步十分簡單, 向繪製縱軸文字時同樣, 獲取到TextPainter
並將其繪製到咱們計算出的座標上.
讓咱們運行起來, 看看效果. 注意控件的底邊部分
咱們來新建一個方法_drawLabel
用於繪製關鍵點:
void _drawLabel(Canvas canvas, double height, List<AltitudePoint> pointList, double ratioX, double ratioY) {
// 繪製關鍵點及文字
canvas.save();
canvas.translate(0.0, height);
for (var p in pointList) {
if (p.name == null || p.name.isEmpty) continue;
// 將海拔的值換算成在屏幕上的值
double yInScreen = (p.point.dy - _minVerticalAxisValue) * ratioY;
// ==== 繪製關鍵點
_signPointPaint.color = p.color;
canvas.drawCircle(Offset(p.point.dx * ratioX, -yInScreen), 2.0, _signPointPaint);
// ==== 繪製文字及背景
var tp = p.textPainter;
var left = p.point.dx * ratioX - tp.width / 2;
// 若是label接近頂端, 調換方向, 避免label看不見
double bgTop = yInScreen + tp.height + 8;
double bgBottom = yInScreen + 4;
double textTop = yInScreen + tp.height + 6;
if (height - bgTop < 0) {
bgTop = yInScreen - tp.height - 8;
bgBottom = yInScreen - 4;
textTop = yInScreen - 6;
}
// 繪製文字的背景框
canvas.drawRRect(
RRect.fromLTRBXY(
left - 2,
-bgTop,
left + tp.width + 2,
-bgBottom,
tp.width / 2.0,
tp.width / 2.0,
),
_signPointPaint);
// 繪製文字
tp.paint(canvas, Offset(left, -textTop));
}
canvas.restore();
}
複製代碼
咱們在參數上就要求double ratioX
和double ratioY
是由於以前咱們已經在_drawLines
方法中計算過了海拔圖映射到屏幕上的比例. 所以咱們只須要在_drawLines
方法體的末尾調用該方法就行了
void _drawLines(Canvas canvas, Size size) {
...
_drawLabel(canvas, size.height, pointList, ratioX, ratioY);
}
複製代碼
首先, 咱們將canvas滾動到最底部. 這樣省的接下來的y軸座標計算都須要height - xxx
了
而後for
循環, 過濾須要進行繪製的關鍵點對其進行繪製
for
內: 首先將沒有name
的點過濾掉. 而後和繪製折線時同樣, 計算出海拔映射到屏幕上時的高度yInScreen
而後咱們先繪製這個關鍵點上的"點", 咱們用canvas.drawCircle
畫了一個圓點. 它的left是當前點的距離映射到屏幕上位置(經過p.point.dx * ratioX
得到), 而top就是剛剛計算出的-yInScreen
. 之因此是負值是由於此前咱們將canvas
滾動到了最底部.
接下來繪製關鍵點上的Label, 這一步比較麻煩一點, 須要計算出label的左上右下四個點的位置. 另外要考慮到若是label超過了控件頂邊(默認咱們是讓label處於"點"的上方的), 須要將本來向上的label變爲向下.
bgTop
表示Label距離頂邊的距離 經過在本來的點的位置基礎上再偏移一個文字的高度+邊距(8
是距離"點"的margin(4)+上下的padding組成)
bgBottom
表示Label距離底邊的距離, 它默認位於"點"的上方4
理論像素的位置
textTop
文字須要在背景框以內, 因此+6
比背景框低一點這樣最終的繪製效果就會顯得文字和背景框之間有一點間距
if (height - bgTop < 0)
表示若是背景框高於頂邊, 將繪製方向變爲向下
下面就是調用canvas.drawRRect
畫一個圓角矩形, 矩形的角度爲文字寬度/2.0
最後繪製文字.
如今從新運行程序, 就能看到密密麻麻的Label了.
後面咱們會根據文章前面部分提到的level
以及縮放的級別展現不一樣的Label
原本我是想一篇文章給寫完的, 可是寫到這裏, 我發現篇幅已經很長了, 而內容還有一大半... 因此我打算分紅上下兩篇(也許是三篇)進行講解.
那麼 盡請期待下篇嘍
level
以及縮放的級別展現不一樣的LabelPicture
對繪製進行優化這篇文章是我發表的第一篇技術文章.
在此以前的很長一段時間, 我都單純只是開源/技術社區的受益者.
一直以來, 我都認爲本身能力有限且過去用到的技術相對比較完善, 不太須要我去寫一些比較基礎的, 尤爲充斥着大量重複的內容.
Flutter 目前尚在初始階段, 不少人都纔剛剛瞭解/接觸到 Flutter, 甚至更多的人都還在觀望狀態, 所以還有大量的技術/資源/教程的空白鬚要填充.
所以種種, 我將個人心得和結果分享出來反饋給社區.
這個項目我作了斷斷續續將近一個月, 一邊學習一邊摸索/試驗, 最終效果我我的仍是很滿意的.
海拔圖控件是目前比較少有的開源庫類型, 也不經常使用. 但但願你們能從本次分享中對Flutter有更多的認識並有所收穫.
我試過不少次嘗試將項目發佈到 pub.dartlang 可是每一次都卡在帳號驗證成功以後...本地終端收不到遠程服務器的回傳. 即便我掛了ss全局代理+命令行終端代理也依然不行. 若是有大佬知道這是什麼問題, 請不吝賜教, 萬分感謝.
若是想要依賴本庫, 能夠直接將源碼拷貝到你的項目中