Flutter實(shí)戰(zhàn)詳解--高仿好奇心日?qǐng)?bào)

前言

最近Flutter一直比較火,我也它也是非常感興趣,看了下官網(wǎng)的基礎(chǔ)教程后我決定直接上手做一個(gè)App,一是這樣學(xué)的比較快印象更加深刻,二是可以記錄其中遇到的一些坑,幫助大家少走一些彎路.本篇文章我會(huì)盡可能詳細(xì)的講到每一個(gè)點(diǎn)上.

項(xiàng)目地址

Github,如果覺得不錯(cuò),歡迎Star

注意事項(xiàng)

1.下載項(xiàng)目后報(bào)錯(cuò)是因?yàn)闆]有添加依賴,在pubspec.yaml文件中點(diǎn)擊Packages get下載依賴,有時(shí)候會(huì)在這里出現(xiàn)卡死的情況,可以配置一下環(huán)境變量.在終端執(zhí)行vi ~/.bash_profile,再添加export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn.詳情請(qǐng)看修改Flutter環(huán)境變量.
2.需要將File Encodings里的Project Encoding設(shè)置為UTF-8,否則有時(shí)候安卓會(huì)報(bào)錯(cuò)
3.如果cocoapods不是最新可能會(huì)出現(xiàn)Error Running Pod Install,請(qǐng)更新cocoapods.
4.由于flutter_webview_plugin這個(gè)插件只支持加載url,于是就需要做一些修改.

  • iOS 在FlutterWebviewPlugin.m文件中的- (void)navigate:(FlutterMethodCall*)call方法中的最后一排,將[self.webview loadRequest:request]方法改為[self.webview loadHTMLString:url baseURL:nil]
  • Android 在WebViewManager.java文件中webView.loadUrl(url)方法改為webView.loadData(url, "text/html", "UTF-8"),以及下面那排的void reloadUrl(String url) { webView.loadUrl(url); }改為void reloadUrl(String url) { webView.loadData(url, "text/html", "UTF-8"); }

先看看效果圖吧.

  • iOS效果圖


    iOS效果圖.gif
  • Android效果圖


    Android效果圖.gif

正題

怎么搭建Flutter環(huán)境我就不多說了,官網(wǎng)上講的很詳細(xì),還沒有搭建開發(fā)環(huán)境的可以看看這個(gè)Flutter中文網(wǎng).

1導(dǎo)航欄Tabbar


這里我用到了DefaultTabController這個(gè)控件,使用DefaultTabController包裹需要用到Tab的頁面即可,它的child為Scaffold,Scaffold有個(gè)appBar屬性,在AppBar中設(shè)置具體的樣式,大家看代碼會(huì)更加清楚.相關(guān)注釋也都寫上了.

 home: new DefaultTabController(
        length: titleList.length,
        child: new Scaffold(
            appBar: new AppBar(
              elevation: 0.0,//導(dǎo)航欄下面那根線
              title: new TabBar(
              isScrollable: false,//是否可滑動(dòng)
              unselectedLabelColor: Colors.black26,//未選中按鈕顏色
              labelColor: Colors.black,//選中按鈕顏色
              labelStyle: TextStyle(fontSize: 18),//文字樣式
              indicatorSize: TabBarIndicatorSize.label,//滑動(dòng)的寬度是根據(jù)內(nèi)容來適應(yīng),還是與整塊那么大(label表示根據(jù)內(nèi)容來適應(yīng))
                indicatorWeight: 4.0,//滑塊高度
                indicatorColor: Colors.yellow,//滑動(dòng)顏色
              indicatorPadding: EdgeInsets.only(bottom: 1),//與底部距離為1
              tabs: titleList.map((String text) {//tabs表示具體的內(nèi)容,是一個(gè)數(shù)組
                return new Tab(
                  text: text,
                );
              }).toList(),
            ),
          ),
              //body表示具體展示的內(nèi)容
              body:TabBarView(children: [News(url: 'http://app3.qdaily.com/app3/homes/index_v2/'),News(url: 'http://app3.qdaily.com/app3/papers/index/')]) ,
        ),
      ),

大家也可以看看官網(wǎng)的示例Flutter官網(wǎng)示例

2. 不同樣式的item

  • 樣式一



    這種布局的大概結(jié)構(gòu)如下


注意這里圖片是緊貼著右邊屏幕的,所以這里需要用到Expanded控件,用于自動(dòng)填充子控件.

  • 樣式二



    這個(gè)樣式的控件布局就很簡(jiǎn)單了,結(jié)構(gòu)如下


  • 樣式三



    這個(gè)和樣式二差不多,只不過最上面多了一塊.

這里需要注意的是,那個(gè)你猜這個(gè)圖片是堆疊在整個(gè)大圖上面的,所以需要用到Stack這個(gè)控件,其中Stack中有個(gè)屬性const FractionalOffset(double dx, double dy)用于表示子控件相對(duì)于父控件的位置

  • 樣式四



    這種樣式稍微復(fù)雜一點(diǎn),結(jié)構(gòu)如下


3數(shù)據(jù)抓取

用青花瓷抓取了好奇心數(shù)據(jù).青花瓷使用教程

image.png

簡(jiǎn)單分析一下,has_more表示是否可以加載更多,last_key用于上拉加載的時(shí)候請(qǐng)求用的,feeds就是每一條數(shù)據(jù),banners就是輪播圖的信息,columns就是橫向滾動(dòng)的ListView的相關(guān)數(shù)據(jù),這個(gè)后面講.接下來就做json序列化相關(guān)的了.

4.Json序列化

首先在pubspec.yaml中導(dǎo)入

dependencies:
json_annotation: ^2.0.0
dev_dependencies:
build_runner: ^1.0.0
json_serializable: ^2.0.0

創(chuàng)建一個(gè)model.dart文件
引入文件

import 'package:json_annotation/json_annotation.dart';
part 'model.g.dart';

其中這個(gè)model.g.dart等會(huì)兒會(huì)自動(dòng)生成.這里需要掌握兩個(gè)知識(shí)點(diǎn)

1.@JsonSerializable() 這是表示告訴編譯器這個(gè)類是需要生成Model類的
2,@JsonKey 由于服務(wù)器返回的部分?jǐn)?shù)據(jù)名稱在Dart語言中是不被允許的,比如has_more,Dart中命名不能出現(xiàn)下劃線,所以就需要用到@JsonKey來告訴編譯器這個(gè)參數(shù)對(duì)于json中的哪個(gè)字段

@JsonSerializable()
class Feed {
  String image;
  int type;
  @JsonKey(name: 'index_type')
  int indexType;
  Post post;
  @JsonKey(name: 'news_list')
  List<News> newsList;
  Feed(this.image,this.type,this.post,this.indexType,this.newsList);
  factory Feed.fromJson(Map<String,dynamic> json) => _$FeedFromJson(json);
  Map<String, dynamic> toJson() => _$FeedToJson(this);
}

好了,寫完后會(huì)報(bào)錯(cuò),因?yàn)?code>FeedFromJson和FeedToJson沒有找到,這個(gè)時(shí)候在控制到輸入flutter packages pub run build_runner build指令后會(huì)自動(dòng)生成一個(gè)moded.g.dart文件,于是在網(wǎng)絡(luò)請(qǐng)求下來數(shù)據(jù)后就可以用Feed feed = Feed.fromJson(data)這個(gè)方法來將Json中數(shù)據(jù)轉(zhuǎn)換保存在Feed這個(gè)實(shí)例中了.在model類中還有些復(fù)雜的Json嵌套,但是也都很簡(jiǎn)單,大家看一眼應(yīng)該就會(huì)了,哈哈.JSON和序列化具體教程

5.輪播圖

Flutter中的輪播圖我用到了Fluuter_Swiper這個(gè)組件,這里設(shè)置小圓點(diǎn)屬性的時(shí)候稍微麻煩了點(diǎn),網(wǎng)上好像也沒有講到,我這里講一下.
首先要?jiǎng)?chuàng)建DotSwiperPaginationBuilder

 DotSwiperPaginationBuilder builder = DotSwiperPaginationBuilder(
        color: Colors.white,//未選中圓點(diǎn)顏色
        activeColor: Colors.yellow,//選中圓點(diǎn)顏色
        size:7,//未選中大小
        activeSize: 7,//選中圓點(diǎn)大小
        space: 5//圓點(diǎn)間距
      );

然后在Swiper中的pagination屬性中設(shè)置它

pagination: new SwiperPagination(
          builder: builder,
        ),
  1. 網(wǎng)絡(luò)請(qǐng)求
    首先,展示頁面要繼承自StatefulWidget,因?yàn)樾枰獎(jiǎng)討B(tài)更新數(shù)據(jù)和列表.
    網(wǎng)絡(luò)請(qǐng)求插件我用的Dio,非常好用.
    initState方法中請(qǐng)求數(shù)據(jù)表示剛加載頁面的時(shí)候進(jìn)行網(wǎng)絡(luò)請(qǐng)求,請(qǐng)求數(shù)據(jù)方法如下
void getData()async{
    if (lastKey == '0'){
      dataList = [];//下拉刷新的時(shí)候?qū)ataList制空
    }
    Dio dio = new Dio();
    Response response = await dio.get("$url$lastKey.json");
    Reslut reslut = Reslut.fromJson(response.data);
    if(!reslut.response.hasMore){
      return;//如果沒有數(shù)據(jù)就不繼續(xù)了
    }
    if(reslut.response.columns != null) {
      columnList = reslut.response.columns;
    }
    lastKey = reslut.response.lastKey;//更新lastkey
    setState(() {
      if (reslut.response.banners != null){
        banners = reslut.response.banners;//給輪播圖賦值
      }
      dataList.addAll(reslut.response.feeds);//給數(shù)據(jù)源賦值
    });
  }

因?yàn)橛玫搅藄etState()方法,所以在該方法中改變了的數(shù)據(jù)會(huì)對(duì)其相應(yīng)的地方進(jìn)行刷新,比如設(shè)置了ListView的itemCount個(gè)數(shù)為dataList.length,如果在SetState方法中dataList.length改變了,那么ListView的itemCount樹也會(huì)自動(dòng)改變并刷新ListView.

7. 上拉刷新與加載

Flutter中有RefreshIndicator用于下拉刷新,它有個(gè)onRefresh閉包方法,表示下拉的時(shí)候執(zhí)行的方法,一般用于網(wǎng)絡(luò)請(qǐng)求.onRefresh方法如下

 Future<void> _handleRefresh() {
    final Completer<void> completer = Completer<void>();
    Timer(const Duration(seconds: 1), () {
      completer.complete();
    });
    return completer.future.then<void>((_) {
      lastKey = '0';
      getData();
    });
  }

下拉加載的話需要初始化一個(gè)ScrollController,將它設(shè)為L(zhǎng)istView的controller,并對(duì)其進(jìn)行監(jiān)聽,當(dāng)滑動(dòng)到最底部的時(shí)候進(jìn)行網(wǎng)絡(luò)請(qǐng)求.

  @override
  void initState() {
      url = widget.url;
      getData();
    _scrollController.addListener(() {
      ///判斷當(dāng)前滑動(dòng)位置是不是到達(dá)底部,觸發(fā)加載更多回調(diào)
      if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
        getData();
      }
    });
  }
  final ScrollController _scrollController = new ScrollController();

上拉加載loading框用到了flutter_spinkit插件,提供了大量的加載樣式.


代碼如下

///上拉加載更多
Widget _buildProgressIndicator() {
  ///是否需要顯示上拉加載更多的loading
  Widget bottomWidget = new Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
    ///loading框
    new SpinKitThreeBounce(color: Color(0xFF24292E)),
    new Container(
      width: 5.0,
    ),
  ]);
  return new Padding(
    padding: const EdgeInsets.all(20.0),
    child: new Center(
      child: bottomWidget,
    ),
  );
}

8. ListView賦值

由于最上面有一個(gè)輪播圖,最下面有加載框,所以ListView的itemCount個(gè)數(shù)為dataList.length+2,又因?yàn)槊總€(gè)item之間都有一個(gè)淺灰色的風(fēng)格線,所以需要用到ListView.separated,具體代碼如下:

 Widget build(BuildContext context) {
    return RefreshIndicator(
      onRefresh:(()=> _handleRefresh()),
      color: Colors.yellow,//刷新控件的顏色
      child: ListView.separated(
        physics: const AlwaysScrollableScrollPhysics(),
        itemCount: _getListCount(),//item個(gè)數(shù)
        controller: _scrollController,//用于監(jiān)聽是否滑到最底部
        itemBuilder: (context,index){
          if(index == 0){
            return SwiperWidget(context, banners);//如果是第一個(gè),則展示banner
          }else if(index < dataList.length + 1){
            return WidgetUtils.GetListWidget(context, dataList[index - 1]);//展示數(shù)據(jù)
          }else {
            return _buildProgressIndicator();//展示加載loading框
          }
        },
        separatorBuilder: (context,idx){//分割線
          return Container(
            height: 5,
            color: Color.fromARGB(50,183, 187, 197),
          );
        },
      ),
    );
  }

9. ListView嵌套橫向滑動(dòng)ListView

這種的話也稍微復(fù)雜一點(diǎn),有兩種樣式.并且到滑到最右邊的時(shí)候可以繼續(xù)請(qǐng)求并加載數(shù)據(jù).




首先來分析一下數(shù)據(jù)



這個(gè)colunmns就是橫向滑動(dòng)列表的重要數(shù)據(jù).

里面的id是請(qǐng)求參數(shù),show_type表示列表的樣式,location表示插入的位置.而且通過抓取接口發(fā)現(xiàn),當(dāng)橫向列表快要展示出來的時(shí)候,才會(huì)去請(qǐng)求橫向列表的具體接口.

那么思路就很清晰了,在請(qǐng)求獲得數(shù)據(jù)后遍歷colunmns,根據(jù)每個(gè)colunmn的location插入一個(gè)Map,如下

data.insert(colunm.location,  {'id':colunm.id,'showType':colunm.showType});

,再創(chuàng)建一個(gè)ColumnsListWidget類,繼承自StatefulWidget,是一個(gè)新item,在滑動(dòng)到該列表的位置的時(shí)候,會(huì)將該Map數(shù)據(jù)傳給ColumnsListWidget,這個(gè)時(shí)候ColumnsListWidget就會(huì)加載數(shù)據(jù)并展示出來了,滑到最右邊的時(shí)候加載和滑到最底部加載的方法一樣,就不多說了.具體可以查看源碼,關(guān)鍵代碼如下:

static Widget GetListWidget(BuildContext context, dynamic data) {
    Widget widget;
    if(data.runtimeType == Feed) {
      if (data.indexType != null) {
        widget = NewsListWidget(context, data);
      } else if (data.type == 2) {
        widget = ListImageTop(context, data);
      } else if (data.type == 0) {
        widget = ActivityWidget(context, data);
      } else if (data.type == 1) {
        widget = ListImageRight(context, data);
      }
    }else{
      widget = ColumnsListWidget(id: data['id'],showType: data['showType'],);
    }

1.橫向ListView外需要用Flexible包裹,Flexible組件可以使Row、Column、Flex等子組件在主軸方向有填充可用空間的能力(例如,Row在水平方向,Column在垂直方向),但是它與Expanded組件不同,它不強(qiáng)制子組件填充可用空間。
2.ListView初始位置用到padding: new EdgeInsets.symmetric(horizontal: 12.0),用padding: EdgeInsets.only(left: 12)的話會(huì)讓ListView和最左邊一直有條線

10.webview加載復(fù)雜的Html字段


獲取到網(wǎng)頁詳情的數(shù)據(jù)發(fā)現(xiàn)是Html字段,并且其中的css是url地址,試了很多Flutter加載Html的插件發(fā)現(xiàn)樣式都不正確,最后決定使用原生和Flutter混編,這時(shí)候發(fā)現(xiàn)flutter_webview_plugin這個(gè)插件是使用原生網(wǎng)頁的,不過它只支持加載url,于是就需要做一些修改.

  • iOS
    FlutterWebviewPlugin.m文件中的- (void)navigate:(FlutterMethodCall*)call方法中的最后一排,將[self.webview loadRequest:request]方法改為[self.webview loadHTMLString:url baseURL:nil]
  • Android
    WebViewManager.java文件中webView.loadUrl(url)方法改為webView.loadData(url, "text/html", "UTF-8"),以及下面那排的void reloadUrl(String url) { webView.loadUrl(url); }改為void reloadUrl(String url) { webView.loadData(url, "text/html", "UTF-8"); }
    由于服務(wù)器端返回的Html中的css和js文件地址是/assets/app3開頭的,所以需要替換成絕對(duì)路徑,所以要用到這個(gè)方法htmlBody.replaceAll( '/assets/app3','http://app3.qdaily.com/assets/app3')
    好了,這下就可以呈現(xiàn)出漂亮的網(wǎng)頁了.

11.ListView嵌套GridView

在點(diǎn)擊橫向滑動(dòng)列表的總標(biāo)題的時(shí)候,會(huì)進(jìn)入到相關(guān)欄目的詳情頁,如圖



這個(gè)ListView包含上下兩部分.上面這部分為:



結(jié)構(gòu)如下

下面就是一個(gè)GridView,不過有時(shí)候下面會(huì)是ListView,根據(jù)shouwType字段來判斷,GridView的代碼如下:

Widget ColumnsDetailTypeTwo(BuildContext context,List<Feed> feesList){
    return GridView.count(
        physics: NeverScrollableScrollPhysics(),
        crossAxisCount: 2,
        shrinkWrap: true,
        mainAxisSpacing: 10.0,
        crossAxisSpacing: 15.0,
        childAspectRatio: 0.612,
        padding: new EdgeInsets.symmetric(horizontal: 20.0),
        children: feesList.map((Feed feed) {
          return  ColumnsTypeTwoTile(context, feed);
        }).toList()
 );
}

其中 childAspectRatio表示寬高比.

圓角頭像需要用到
CircleAvatar(backgroundImage:NetworkImage(url),),這個(gè)控件

12 在切換Tab的時(shí)候防止執(zhí)行initState

在切換頂部tab的時(shí)候會(huì)發(fā)現(xiàn)下面的界面會(huì)自動(dòng)滑動(dòng)到頂(位置重置)并執(zhí)行initState,同時(shí)每次滑到橫向ListView的時(shí)候,它也會(huì)執(zhí)行initState并且位置也會(huì)重置,要讓它只執(zhí)行一次initState方法的話需要這么做.

class _XXXState extends State<XXX> with AutomaticKeepAliveClientMixin{
  @override
  bool get wantKeepAlive => true;

這樣它就會(huì)只執(zhí)行一次initState方法了.

總結(jié)

做了這個(gè)項(xiàng)目最大的感受就是界面布局是真的很方便很簡(jiǎn)單,因?yàn)樽隽艘槐閷?duì)很多知識(shí)點(diǎn)也理解的更深了.如果覺得有幫助到你的話,希望可以給個(gè) Star

項(xiàng)目地址

Github

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容