Flutter Weather天气模块实现

本文介绍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实现

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如下:


first

一张充满屏幕的背景图片和上下两部分的天气信息。

背景实现
背景

使用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 滚动系数。
看一下它的实现类:

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,069评论 4 358
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,212评论 1 287
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 105,912评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,424评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,741评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,194评论 1 206
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,553评论 2 307
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,289评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,923评论 1 237
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,251评论 2 240
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,775评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,144评论 2 249
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,698评论 3 228
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,936评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,658评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,214评论 2 267
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,159评论 2 258

推荐阅读更多精彩内容

  • 最近新搬了住处,来说说我与国内两大超级餐饮连锁品牌之间的事情。 兰州拉面 前天...
    牌牌阅读 329评论 0 1
  • 钠遇见水, 便会躁动不已, 而我遇见你, 则会心动不已。 ----作者:南方 ----配图:姚姚
    我是南方呀阅读 179评论 0 1
  • 脑容量如同电脑的内存条,容量是有限的。并且通常情况实际储存容量比标的容量较小,一个2G的内存条实际上的容量或许只有...
    一个景天阅读 1,459评论 1 1
  • 昨天下午Fresa推荐了一个治愈系电影,叫做何以为家,强忍住自己没去看影片简介,就怀着一切都未知的心情去看这部影片...
    chocofresa阅读 418评论 0 1