# 『Flutter-绘制篇』自定义View在天气 APP 中的实战应用

• 动态降雨折线图

rain_chart
• 多日折线图

day_chart
• 24小时折线图

hour_chart
• AQI圆弧

aqi_chart
• 日出日落图

sun_chart

## 绘制

### AQI圆弧

aqi_chart

``````  @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();
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();
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));
}
``````

1. 先绘制半透明圆弧，确认中心点坐标和半径，通过 `_path.addArc(Rect oval, double startAngle, double sweepAngle)` 方法进行绘制。oval: 圆弧所在矩形，startAngle: 起始角度(以钟表为例，0为3点方向)，sweepAngle: 划过角度(默认方向顺时针)。

2. 在半透明圆弧基础上，根据 ratio (`currentAqiValue / totalAqiValue`) 绘制纯白色圆弧

3. 依次绘制中间 AQIValueAQIDesc。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.pushStyle(ui.TextStyle(color: color));
var paragraph = pb.build()..layout(ui.ParagraphConstraints(width: itemWidth));
return paragraph;
}
``````

### 日出日落贝塞尔曲线

sun_chart

1. 绘制 虚曲线，首先确认起点和终点，通过 `_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);
``````
2. 绘制 实虚线，这里遇到一个问题，已知比例 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()` 对虚曲线裁剪最终得到实曲线。

3. 绘制小太阳和当前时间，知道曲线上的 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`

### 多日折线图

• 多日折线图

day_chart

1. 遍历找出 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;
}
});
}
``````
2. 根据温度计算x,y值，目前已知折线的高度 itemHeight, 具体温度 temp,起点 topLineStartY，最高最低温度已经实际温度，即可算出温度对应的 y 坐标值，x坐标值

``````getTopLineY(int temp) {
if (temp == topMaxTemp) {
}
(topMaxTemp - temp) / (topMaxTemp - topMinTemp) * lineHeight;
}
x = startX + index*itemWidth;
``````
3. 开始绘制，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);
}
``````

### 动态降雨折线图

rain_chart

#### 背景

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));

}
``````

#### 渐变折线

1. 绘制折线，最大值不用计算已经知道 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();
``````
2. 渐变效果，复用折线 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;
canvas.drawPath(_linePath, _linePaint);
``````

#### 入场动画

``````_controller =
AnimationController(duration: Duration(milliseconds: 250), vsync: this);
CurvedAnimation(parent: _controller, curve: Curves.linear);
setState(() {
_ratio = _controller.value;
});
});
``````

## 总结

1. 对 待实现效果进行分析，找出关键信息进行分层分步，找出静态数据和动态数据，也就是常量和变量。
2. 计算好基础数据，比如整体宽高，单位宽高，起始值，最大最小值
3. 有了数据支撑，根据效果调用对应的绘制 API，设置 paint 的相关属性，完成绘制
4. 如果有动画，以控制变量作为切入口，动画本身只关注变量值的改变，而不用考虑变量对绘制的影响