牛笔!自己用Flutter撸一个天气APP

本身作为天气 APP,自定义绘制自然少不了,首页多样的背景效果,炫酷的雨雪效果(https://juejin.im/post/6867489001809379335),展示当前空气质量和体感的圆环效果,动态温度折线图和日出日落图。

其实 pub.dev(https://pub.flutter-io.cn/packages?q=chart)上已经有不少 chart 插件,提供丰富的图表类型,支持各种动画和手势。

但是如果是像本项目,使用场景并不需要手势,且没有复杂的动画,只存在折线这种形态,完全可以自己实现。一方面可以巩固和拓展 flutter 的绘制相关知识点,另一方面根据自己的实际需求,可以拥有更多的定制化功能。

先看一下最终效果,其中包括:

  • 动态降雨折线图

  • 多日折线图

  • 24小时折线图

  • AQI圆弧

  • 日出日落图

绘制

接下来,会以上述效果作为切入点,由简到难,由静态到动态,逐步分析绘制前数据的准备和绘制时相关接口调用,最后,总结出折线图绘制的通用思路,对后续有相关需求的小伙伴提供帮助。

AQI圆弧

先从最简单圆

弧图开始,如上图可看到的信息有:半透明的圆弧,纯白色的圆弧,居中的 AQI 值以及其底部的文字描述。对于此图而言,只需要知道 ratio: 白色圆弧占比、AQIValue 和 AQIDesc。这个简单直接先上代码再分析。

@override
void paint(Canvas canvas, Size size) {
weatherPrint(“AqiChartPainter size: s i z e " ) ; v a r   r a d i u s   =   s i z e . h e i g h t   /   2   −   10 ; v a r   c e n t e r X   =   s i z e . w i d t h   /   2 ; v a r   c e n t e r Y   =   s i z e . h e i g h t   /   2 ; v a r   c e n t e r O f f s e t   =   O f f s e t ( c e n t e r X ,   c e n t e r Y ) ; / /  绘制半透明圆 弧 p a t h . r e s e t ( ) ; p a t h . a d d A r c ( R e c t . f r o m C i r c l e ( c e n t e r :   c e n t e r O f f s e t ,   r a d i u s :   r a d i u s ) , p i   ∗   0.7 ,   p i   ∗   1.6 ) ; p a i n t . s t y l e   =   P a i n t i n g S t y l e . s t r o k e ; p a i n t . s t r o k e W i d t h   =   4 ; p a i n t . s t r o k e C a p   =   S t r o k e C a p . r o u n d ; p a i n t . c o l o r   =   C o l o r s . w h i t e 38 ; c a n v a s . d r a w P a t h ( p a t h ,   p a i n t ) ; / /  绘制纯白色圆 弧 p a t h . r e s e t ( ) ; p a t h . a d d A r c ( R e c t . f r o m C i r c l e ( c e n t e r :   c e n t e r O f f s e t ,   r a d i u s :   r a d i u s ) , p i   ∗   0.7 ,   p i   ∗   1.6   ∗   r a t i o ) ; p a i n t . c o l o r   =   C o l o r s . w h i t e ; c a n v a s . d r a w P a t h ( p a t h ,   p a i n t ) ; / /  绘制  A Q I V a l u e v a r   v a l u e P a r a   =   U i U t i l s . g e t P a r a g r a p h ( v a l u e ,   30 ) ; c a n v a s . d r a w P a r a g r a p h ( v a l u e P a r a , O f f s e t ( c e n t e r O f f s e t . d x   −   v a l u e P a r a . w i d t h   /   2 , c e n t e r O f f s e t . d y   −   v a l u e P a r a . h e i g h t   /   2 ) ) ; / /  绘制  A Q I D e s c v a r   d e s c P a r a   =   U i U t i l s . g e t P a r a g r a p h ( " 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(" 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);// 绘制 AQIValuevar valuePara = UiUtils.getParagraph(value, 30);canvas.drawParagraph(valuePara,Offset(centerOffset.dx  valuePara.width / 2,centerOffset.dy  valuePara.height / 2));// 绘制 AQIDescvar 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.**依次绘制中间 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。继续分解步骤:
**1.**绘制 虚曲线,首先确认起点和终点,通过 _path.quadraticBezierTo(double x1, double y1, double x2, double y2) 绘制贝塞尔曲线,参数需要传入 控制点 坐标和 终点 坐标。很遗憾 Flutter 没有提供虚线的接口,借用 path_drawing 插件中的 dashPath(Path source, {@required CircularIntervalList 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([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 = “ n o w . h o u r : {now.hour}: 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 值和遍历绘制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) {
return topLineStartY;
}
return topLineStartY +
(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(“ e l e m e n t . d a y T e m p ° " ,   m a i n T e x t S i z e ,   i t e m W i d t h :   i t e m W i t h ) ; c a n v a s . d r a w P a r a g r a p h ( t o p T e m p P a r a ,   O f f s e t ( t o p O f f s e t . d x   −   t o p T e m p P a r a . w i d t h   /   2 ,   t o p O f f s e t . d y   −   t o p T e m p P a r a . h e i g h t   −   5 ) ) ; v a r   b o t t o m T e m p P a r a   =   U i U t i l s . g e t P a r a g r a p h ( " {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(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);
}

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则近万的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

img

img

img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)

尾声

在我的博客上很多朋友都在给我留言,需要一些系统的面试高频题目。之前说过我的复习范围无非是个人技术博客还有整理的笔记,考虑到笔记是手写版不利于保存,所以打算重新整理并放到网上,时间原因这里先列出面试问题,题解详见:


展示学习笔记

《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门即可获取!

记是手写版不利于保存,所以打算重新整理并放到网上,时间原因这里先列出面试问题,题解详见:

[外链图片转存中…(img-lAo63oJv-1712017705735)]
展示学习笔记
[外链图片转存中…(img-0M2x9v1m-1712017705735)]
[外链图片转存中…(img-D4bOBhtd-1712017705735)]

《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门即可获取!
  • 30
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值