转载请标明出处: 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全局代理+命令行终端代理也依然不行. 若是有大佬知道这是什么问题, 请不吝赐教, 万分感谢.
若是想要依赖本库, 能够直接将源码拷贝到你的项目中