Flutter Weather天氣模組實現

正義啊發表於2019-07-31

本文介紹Flutter_Weather天氣模組實現。效果圖如下:

在這裡插入圖片描述
完整專案地址:github.com/Zhengyi66/F…

首頁最外層佈局實現

首頁包含一個頂部的城市名稱展示欄和一個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

    //跳轉頁面並接收返回值
     Future future = Application.router.navigateTo(context, Routes.cityPage);
     //接收回撥資訊
    future.then((value){
      if(value != null){
       }
     });
複製程式碼

資料載入

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的解析了。 可以在下面連結中找到 github.com/Zhengyi66/F…

利用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
再來看一下最外層ScrollView的佈局程式碼:
在這裡插入圖片描述
看到這個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上了。github.com/Zhengyi66/F…

相關文章