本文介绍Flutter_Weather天气模块实现。效果图如下:
项目地址:https://github.com/Zhengyi66/Flutter_weather
首页最外层布局实现
首页包含一个顶部的城市名称展示栏和一个pageview。因此可以使用一个Column
竖直的列进行包裹。
return Container(
child: Column(
children: <Widget>[
//头
buildBar(context),
//pageview
Expanded(child: _buildPageView(),
)
],
),
);
使用Expanded
填充剩余空间,类似Android权重属性。
PageView实现
_buildPageView()
根据 loadState加载状态不同返回3个widget。加载数据时返回一个自定义的ProgressView,加载失败时返回一个失败的Widget,只有当数据加载成功时,才返回PageView。
PageView属性:
- scrollDirection :滚动方向。 Axis.horizontal 横向 vertical竖向
- controller : PageController 控制pageview滚动
- pageSnapping : 默认为true。设置false后失去pageview的特性
顶部标题栏实现
如上图,横向排列的3个widget,可以使用Row进行包裹。使用GestureDetector为其增加点击事件。代码如下:
选择城市之后我们需要知道选择了什么城市,所以我们需要接受路由的回调Future,并添加它的回调方法,在回调方法中获得返回的城市然后重新加载数据。类似Android activityresult
数据加载
1、加载assets中json数据
因为数据调用的次数是有限制的,所以在调试的时候只能加载本地的数据了╮(╯▽╰)╭
//从assets中加载天气信息
loadWeatherAssets() async {
Future<String> future = DefaultAssetBundle.of(context).loadString("assets/json/weather.json");
future.then((value){
setState(() {
weatherJson = value;
});
});
}
flutter推荐我们使用DefaultAssetBundle
进行本地数据加载。
加载网络数据
loadWeatherData() async {
final response = await http.get(Api.WEATHER_QUERY + city);
setState(() {
weatherJson = response.body;
});
}
你没看错,就一行代码就搞定了数据加载。当然要使用await
来等待加载完成,因为有等待,所以加载的方法要async
在异步中进行。
Json解析
加载完数据以后进行json解析
导包
import 'dart:convert';
if(weatherJson.isNotEmpty){
WeatherBean weatherBean = WeatherBean.fromJson(json.decode(weatherJson));
if(weatherBean.succeed()){
loadState = 1;
weatherResult = weatherBean.result;
}else{
loadState = 0;
}
}
json.decode()
返回的是一个dynamic
任意类型。因此需要我们在手动解析。
解析对象
WeatherBean中实现如下:
我们需要手动写一个工厂方法
WeatherBean.fromJson(Map<String,dynamic> json)
手动解析。
如果解析的key是一个对象,例如上面的WeatherResult
对象。则需要调用WeatherResult对象的fromJson。
为了保险起见,解析WeatherResult对象的时候加一个非空判断。
解析数组
我们再来看一下WeatherResult中又是啥。(有点多,截屏截不全了╮(╯▽╰)╭,就拷贝吧)
class WeatherResult{
final String city; //城市
final String citycode; //城市code (int)
...(省略一些)
final Aqi aqi;
final List<WeatherIndex> indexs; //生活指数
final List<WeatherDaily> dailys; //一周天气
final List<WeatherHourly> hours; //24小时天气
WeatherResult({this.city,this.citycode,this.date,this.weather,this.temp,this.temphigh,this.templow,this.img,this.humidity,
this.pressure,this.windspeed,this.winddirect,this.windpower,this.updatetime,this.week,this.aqi,this.indexs,this.dailys,this.hours});
factory WeatherResult.fromJson(Map<String,dynamic> json){
//先解析成数组
var temIndexs = json['index'] as List;
//然后把数组中的每个值转成WeatherIndex对象(调用WeatherIndex.fromJson(i))
List<WeatherIndex> indexList = temIndexs.map((i)=>WeatherIndex.fromJson(i)).toList();
var temDailys = json['daily'] as List;
//把数组中的每个值转成WeatherDaily对象(调用WeatherDaily.fromJson(i))
List<WeatherDaily> dailyList = temDailys.map((i)=>WeatherDaily.fromJson(i)).toList();
var temHours = json['hourly'] as List;
//把数组中的每个值转成WeatherHourly对象(调用WeatherHourly.fromJson(i))
List<WeatherHourly> hoursList = temHours.map((i)=>WeatherHourly.fromJson(i)).toList();
return WeatherResult(
city: json['city'],
citycode: json['citycode'].toString(),
...(省略一些)
aqi: Aqi.fromJson(json['aqi']),
indexs: indexList,
dailys: dailyList,
hours: hoursList
);
}
}
解析数组的时候首先将其解析成一个没有指定类型的List,然后遍历数组中的每项数据,将每一项转换成对应的对象。
//先解析成数组
var temIndexs = json['index'] as List;
//然后把数组中的每个值转成WeatherIndex对象(调用WeatherIndex.fromJson(i))
List<WeatherIndex> indexList = temIndexs.map((i)=>WeatherIndex.fromJson(i)).toList();
这里就不在贴出WeatherIndex、WeatherDaily、WeatherHourly的解析了。
可以在下面链接中找到 https://github.com/Zhengyi66/Flutter_weather/blob/master/lib/model/weather_bean.dart
利用PageController暂时解决滑动冲突
我上面其实在Pageview中有使用PageController的。
因为我们的pageview中嵌套了scrollview,两个listview和一个gridview,所以肯定会存在滑动冲突的。使用PageController判断第一个pageview是否滑动完成,即是否已经滑动到第二个页面了。
PageController _pageController = new PageController();
@override
void initState() {
super.initState();
loadWeatherData();
_pageController.addListener((){
//判断第一个pageview是否完成滑动
if( _pageController.position.pixels == _pageController.position.extentInside){
//滑动完成,到第二个页面后。发送消息给第二个页面
eventBus.fire(PageEvent());
}
});
}
FirstPageView实现
pageview中包裹了两个子view,FirstPageView和SecondPageView。
第一个pageview如下:
一张充满屏幕的背景图片和上下两部分的天气信息。
背景实现
使用Stack实现布局的层级嵌套,背景在最底层,天气信息在上层。
Stack的 fit属性要设置StackFit.expand填充,不然图片不会充满全屏。
天气信息实现
天气布局 整体可以分为头部,底部和中间的空白。所以使用Column竖直布局来包裹。中间空白使用Expanded填充。
1、头部天气实现
最外层是一个横向排列的Row布局,中间使用Expanded填充。
左边黄色框内内容使用Column包裹。Column中包含一个Stack和一个Container。
因为这个页面用了很多Stack布局,所以展示一个蓝色框内Stack的实现:
//左边温度信息
Container(width: 200,height: 90,
child: Stack(
alignment: Alignment.center,
fit: StackFit.expand,
children: <Widget>[
Positioned(
child: Text(result.temp,style:
TextStyle(color:Colors.white,fontSize: 90,fontWeight: FontWeight.w200),),
left: 10,
),
Positioned(
child: Text("℃",style: TextStyle(color: Colors.white,fontSize: 20,fontWeight: FontWeight.w300),),
left: 110,
top: 5,
),
Positioned(child: Text(result.weather,
style: TextStyle(color: Colors.white,fontSize: 18),maxLines: 1,overflow: TextOverflow.ellipsis,),
bottom: 5,
left: 110,
)
],
),
),
Stack属性:
- alignment :Alignment.center 对齐方式, 居中
- fit: StackFit.expand, 适应方式 填充
使用Positioned来调整子widget在Stack中的位置 :通过距离 left、top、right、bottom 的距离来确定位置
2、底部信息实现。
底部布局就是一个Row和两个相同的Stack。为了使左右连个Stack能够平分宽度,可以使用Expanded进行包裹。
Expand有个属性flex默认为1,类似Android的权重。
SecondPageView实现
布局分析
如上图。最外层是一个Stack,里面包裹一个背景图片,图片的上面是一个ScrollView(也可以是ListView 最开始用的就是listview,但是用了listview上面会有一小段空白,listview不能充满全屏,应该是我布局时候出来点毛病吧。)
然后ScrollView中包裹一个Column。代码如下
因为这里有一个加载assets中image的过程,所以加一个imageLoaded图片是否加载完成的判断。加载完成才显示内容。
1、_buildTitle实现
//标题widget
Widget _buildTitle(String title) {
return Container(
padding: EdgeInsets.all(10),
child: Text(
title,
style: TextStyle(color: Colors.white70, fontSize: 16),
),
);
}
就是一个简单的Text。为了复用所以写成方法
2、_buildLine实现
//线widget
Widget _buildLine({double height, Color color}) {
return Container(
height: height == null ? 0.5 : height,
color: color ?? Colors.white,
);
}
就是一个线,可以选择高度和颜色
3、24小时天气实现
//24小时天气widget
Widget _buildHour(List<WeatherHourly> hours) {
List<Widget> widgets = [];
for(int i=0; i<hours.length; i++){
widgets.add(_getHourItem(hours[i]));
}
return Container(
chil(
scrollDirection: Axis.horizontal,
child: Row(
children: widgets,
),
),
);
}
就是一个简单的横向的scrollview。
4、 一周的天气
//多天天气
Widget _buildDaily(List<WeatherDaily> dailys,List<ui.Image> dayImages,List<ui.Image> nightImages){
return Container(
height: 310,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: WeatherLineWidget(dailys, dayImages,nightImages),
),
);
}
可以看到这也是一个简单的Scrollview,里面包裹一个我们自定义的WeatherLineWidget
自定义天气折线图
一些初始化如下:
class WeatherLineWidget extends StatelessWidget {
WeatherLineWidget(this.dailys,this.dayIcons,this.nightIcons);
final List<WeatherDaily> dailys;
final List<ui.Image> dayIcons;
final List<ui.Image> nightIcons;
@override
Widget build(BuildContext context) {
// TODO: implement build
return CustomPaint(
painter: _customPainter(dailys,dayIcons,nightIcons),
size: Size(420, 310),//自定义Widget的宽高
);
}
}
class _customPainter extends CustomPainter {
_customPainter(this.dailys,this.dayImages,this.nightIcons);
List<WeatherDaily> dailys; //数据源
List<ui.Image> dayImages; //白天天气image
List<ui.Image> nightIcons;//夜间天气image
final double itemWidth = 60; //每个item的宽度
final double textHeight = 120; //显示文字的高度
final double temHeight = 80; //温度区域的高度
int maxTem, minTem; //最高/低温度
@override
void paint(Canvas canvas, Size size) async{
}
}
然后在paint()方法中做绘制操作。
1、获得最高最低温度
//设置最高温度,最低温度
setMinMax(){
minTem = maxTem = int.parse(dailys[0].day.temphigh);
for(WeatherDaily daily in dailys){
if(int.parse(daily.day.temphigh) > maxTem){
maxTem = int.parse(daily.day.temphigh);
}
if(int.parse(daily.night.templow) < minTem){
minTem = int.parse(daily.night.templow);
}
}
}
2、绘制文字的方法
//绘制文字
drawText(Canvas canvas, int i,String text,double height,{double frontSize}) {
var pb = ui.ParagraphBuilder(ui.ParagraphStyle(
textAlign: TextAlign.center,//居中
fontSize: frontSize == null ?14:frontSize,//大小
));
//添加文字
pb.addText(text);
//文字颜色
pb.pushStyle(ui.TextStyle(color: Colors.white));
//文本宽度
var paragraph = pb.build()..layout(ui.ParagraphConstraints(width: itemWidth));
//绘制文字
canvas.drawParagraph(paragraph, Offset(itemWidth*i, height));
}
和Android不同的是,Flutter绘制文字使用drawParagraph()
方法
3、paint()方法
@override
void paint(Canvas canvas, Size size) async{
setMinMax();
List<Offset> maxPoints = [];
List<Offset> minPoints = [];
double oneTemHeight = temHeight / (maxTem - minTem); //每个温度的高度
for(int i=0; i<dailys.length; i++){
var daily = dailys[i];
var dx = itemWidth/2 + itemWidth * i;
var maxDy = textHeight + (maxTem - int.parse(daily.day.temphigh)) * oneTemHeight;
var minDy = textHeight + (maxTem - int.parse(daily.night.templow)) * oneTemHeight;
var maxOffset = new Offset(dx, maxDy);
var minOffset = new Offset(dx, minDy);
if(i == 0){
maxPath.moveTo(dx, maxDy);
minPath.moveTo(dx, minDy);
}else {
maxPath.lineTo(dx, maxDy);
minPath.lineTo(dx, minDy);
}
maxPoints.add(maxOffset);
minPoints.add(minOffset);
if(i != 0){
//画竖线
canvas.drawLine(Offset(itemWidth * i ,0), Offset(itemWidth * i, textHeight*2 + textHeight), linePaint);
}
var date;
if(i == 0){
date = daily.week + "\n" + "今天";
}else if(i == 1){
date = daily.week + "\n" + "明天";
}else{
date = daily.week + "\n" + TimeUtil.getWeatherDate(daily.date);
}
//绘制日期
drawText(canvas, i, date ,10);
//绘制白天天气图片 src原始矩阵 dst输出矩阵
canvas.drawImageRect(dayImages[i],Rect.fromLTWH(0, 0, dayImages[i].width.toDouble(), dayImages[i].height.toDouble()),
Rect.fromLTWH(itemWidth/4 + itemWidth*i, 50,30,30),linePaint);
//绘制白天天气
drawText(canvas, i, daily.day.weather, 90);
//绘制夜间天气图片
canvas.drawImageRect(nightIcons[i],Rect.fromLTWH(0, 0, nightIcons[i].width.toDouble(), nightIcons[i].height.toDouble()),
Rect.fromLTWH(itemWidth/4 + itemWidth*i, textHeight + temHeight + 10,30,30),new Paint());
//绘制夜间天气信息
drawText(canvas, i, daily.night.weather, textHeight+temHeight + 45);
//绘制风向和风力
drawText(canvas, i, daily.night.winddirect + "\n" + daily.night.windpower, textHeight+temHeight + 70,frontSize: 10);
}
//最高温度折线
canvas.drawPath(maxPath, maxPaint);
//最低温度折线
canvas.drawPath(minPath, minPaint);
//最高温度点
canvas.drawPoints(ui.PointMode.points, maxPoints, pointPaint);
//最低温度点
canvas.drawPoints(ui.PointMode.points, minPoints, pointPaint);
绘制其实还是挺简单的。注意一下drawImageRect
drawImageRect(Image image, Rect src, Rect dst, Paint paint)
- image是包
'dart:ui'
中的image,不是widget。 - src 源image的 rect
- dst 输出image 的 rect。可以通过修改此widget的大小达到修改图片大小的效果
加载drawImageRect()中的image
import 'dart:async';
import 'dart:ui' as ui;
import 'dart:typed_data';
initNightIcon(String path) async {
final ByteData data = await rootBundle.load(path);
ui.Image image = await loadNightImage(new Uint8List.view(data.buffer));
}
//加载image
Future<ui.Image> loadNightImage(List<int> img) async {
final Completer<ui.Image> completer = new Completer();
ui.decodeImageFromList(img, (ui.Image img){
return completer.complete(img);
});
return completer.future;
}
pageview的滑动冲突
我觉得这算是一种取消的方式吧,我也想用其他方法,关键其他方式我也没找到╮(╯▽╰)╭。
这里面用到了scroll中的一个很关键的属性physics
: ScrollPhysics 滚动系数。
看一下它的实现类:
再来看一下最外层的布局代码:
看到这个
getScrollPhysics()
方法了么。
//获得滑动系数
ScrollPhysics getScrollPhysics(bool top){
if(top){
return NeverScrollableScrollPhysics();
}else{
return BouncingScrollPhysics();
}
}
top:scrollview是否滑动到顶部。
当scrollview滑动到顶部的时候,physics为NeverScrollableScrollPhysics(),禁止scroll滚动。
当scrollview不在顶部的时候,physics为BouncingScrollPhysics(), 弹性滚动。
下面就是对scrollview的是不是到达顶部的状态监听了。
class _PageState extends State<SecondPageView> {
ScrollController _scrollController = new ScrollController();
bool top = false;
StreamSubscription streamSubscription;
@override
void initState() {
// TODO: implement initState
super.initState();
top = false;
//控制ListView的滑动属性
_scrollController.addListener(() {
if (_scrollController.position.pixels ==
_scrollController.position.maxScrollExtent) {
// print("滑动到底部");
} else if (_scrollController.position.pixels ==
_scrollController.position.minScrollExtent) {
// print("滑动到顶部");
setState(() {
top = true;
});
} else {
top = false;
}
});
//接收pageview的滑动事件,此时page已经滑动到第二个页面了,修改physics属性
streamSubscription = eventBus.on<PageEvent>().listen((event) {
setState(() {
top = false
;
});
});
}
@override
void dispose() {
top = false;
if (streamSubscription != null) {
streamSubscription.cancel();
}
super.dispose();
}
}
通过_scrollController
和注册的pageview的滚动事件一起来确定scrollview是否可以滚动。
结束
这里就是天气模块的内容了,完整代码已经上传到GitHub上了。https://github.com/Zhengyi66/Flutter_weather