Flutter滾動型容器組件 - ListView篇

圖片描述

1. 前言

Flutter作爲時下最流行的技術之一,憑藉其出色的性能以及抹平多端的差異優勢,早已引起大批技術愛好者的關注,甚至一些閒魚美團騰訊等大公司均已投入生產使用。雖然目前其生態還沒有完全成熟,但身靠背後的Google加持,其發展速度已經足夠驚人,可以預見將來對Flutter開發人員的需求也會隨之增長。

無論是爲了技術嚐鮮還是以後可能的工作機會,都9102年了,作爲一個前端開發者,似乎沒有理由不去嘗試它。正是帶着這樣的心理,筆者也開始學習Flutter,同時建了一個用於練習的倉庫,後續所有代碼都會託管在上面,歡迎star,一起學習。

在上一篇文章中,我們學習了Flutter中使用頻率最高的一些基礎組件。但是在一些場景中,當組件的寬度或高度超出屏幕邊緣時,Flutter往往會給出overflow警告,提醒有組件溢出屏幕。爲了解決這個問題,今天我們就來學習最常用的一個滾動型容器組件ListView組件

2. ListView使用方法

從功能比較上來看,Flutter中的ListView組件和RN中的ScrollView/FlatList非常相似,但是在使用方法上還是有點區別。接下來,就跟着我一起來看看ListView組件都有哪些常用的使用方法。

2.1 ListView()

第一種使用方法就是直接調用其默認構造函數來創建列表,效果等同於RN中的ScrollView組件。但是這種方式創建的列表存在一個問題:對於那些長列表或者需要較昂貴渲染開銷的子組件,即使還沒有出現在屏幕中但仍然會被ListView所創建,這將是一項較大的開銷,使用不當可能引起性能問題甚至卡頓

不過話說回來,雖然該方法可能會有性能問題,但還是取決於其不同的應用場景而定。下面我們就來看下其構造函數(已省略不常用屬性):

ListView({
  Axis scrollDirection = Axis.vertical,
  ScrollController controller,
  ScrollPhysics physics,
  bool shrinkWrap = false,
  EdgeInsetsGeometry padding,
  this.itemExtent,
  double cacheExtent,
  List<Widget> children = const <Widget>[],
})
  • scrollDirection: 列表的滾動方向,可選值有Axishorizontalvertical,可以看到默認是垂直方向上滾動;
  • controller : 控制器,與列表滾動相關,比如監聽列表的滾動事件;
  • physics: 列表滾動至邊緣後繼續拖動的物理效果,AndroidiOS效果不同。Android會呈現出一個波紋狀(對應ClampingScrollPhysics),而iOS上有一個回彈的彈性效果(對應BouncingScrollPhysics)。如果你想不同的平臺上呈現各自的效果可以使用AlwaysScrollableScrollPhysics,它會根據不同平臺自動選用各自的物理效果。如果你想禁用在邊緣的拖動效果,那可以使用NeverScrollableScrollPhysics
  • shrinkWrap: 該屬性將決定列表的長度是否僅包裹其內容的長度。當ListView嵌在一個無限長的容器組件中時,shrinkWrap必須爲true,否則Flutter會給出警告;
  • padding: 列表內邊距;
  • itemExtent: 子元素長度。當列表中的每一項長度是固定的情況下可以指定該值,有助於提高列表的性能(因爲它可以幫助ListView在未實際渲染子元素之前就計算出每一項元素的位置);
  • cacheExtent: 預渲染區域長度,ListView會在其可視區域的兩邊留一個cacheExtent長度的區域作爲預渲染區域(對於ListView.buildListView.seperated構造函數創建的列表,不在可視區域和預渲染區域內的子元素不會被創建或會被銷燬);
  • children: 容納子元素的組件數組。

上面的屬性介紹一大堆,都不如一個實際例子來得實在。我們可以用一個ListView組件來包裹上篇文章中實現的銀行卡寵物卡片朋友圈這三個例子:

代碼(文件地址

class NormalList extends StatelessWidget {

  const NormalList({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView(
      children: <Widget>[
        CreditCard(data: creditCardData),
        PetCard(data: petCardData),
        FriendCircle(data: friendCircleData),
      ],
    );
  }
}

預覽

圖片描述

可以看到,默認構造函數的用法非常之簡單,直接把子元素組件放在children數組中就可以了。但是潛在的問題前面也已經解釋過,對於長列表這種應用場景還是應該用ListView.build構造函數性能會更好。

2.2 ListView.build()

ListView默認構造函數雖使用簡單,但不適用於長列表。爲此,我們來看下ListView.build構造函數:

ListView.builder({
  ...
  int itemCount,
  @required IndexedWidgetBuilder itemBuilder,
})

這裏省略了不常用以及和ListView默認構造函數重複的一些參數,相比之下我們可以發現ListView.builder多了兩個新的參數:

  • itemCount: 列表中元素的數量;
  • itemBuilder: 子元素的渲染方法,允許自定義子元素組件(等同於rnFlatList組件的renderItem屬性)。

不同於ListView默認構造函數通過children參數指定子元素的這種方式,ListView.build通過暴露統一的itemBuilder方法將渲染子元素的控制權交還給調用方。這裏我們用一個微信公衆號的例子來說明ListView.build的使用方法(公衆號卡片的樣式佈局可以看這裏,也算是對基礎組件的一個鞏固和複習):

代碼(文件地址

class SubscribeAccountList extends StatelessWidget {
  const SubscribeAccountList({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Color(0xFFEFEFEF),
      child: ListView.builder(
        itemCount: subscribeAccountList.length,
        itemBuilder: (context, index) {
          return SubscribeAccountCard(data: subscribeAccountList[index]);
        },
      ),
    );
  }
}

預覽

builder_usage

根據上面的代碼可以看到,ListView.build創建列表最重要的兩個參數就是itemCountitemBuilder。對於公衆號列表這個例子,由於每個公衆號消息卡片的佈局都是有規則的,而且這個列表的數量可能非常之多,所以用ListView.build來創建再適合不過了。

2.3 ListView.seperated()

絕大多數列表類的需求我們都可以用ListView.build構造函數來解決問題,不過有的列表子項之間需要分割線,此時我們可以用Flutter提供的另一個構造函數ListView.seperated來創建列表。來看下其構造函數有什麼不同:

ListView.separated({
  ...
  @required IndexedWidgetBuilder separatorBuilder
})

相比於ListView.build 構造函數,可以看到ListView.seperated僅僅是多了一個separatorBuilder必填參數。顧名思義,這就是暴露給調用方自定義分割線組件的回調方法。以支付寶的好友列表爲例(好友卡片的樣式佈局可以看這裏),我們來看下ListView.seperated的使用方法:

代碼(文件地址

class FriendList extends StatelessWidget {
  const FriendList({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      itemCount: friendListData.length,
      itemBuilder: (context, index) {
        return FriendCard(data: friendListData[index]);
      },
      separatorBuilder: (context, index) {
        return Divider(
          height: .5,
          indent: 75,
          color: Color(0xFFDDDDDD),
        );
      },
    );
  }
}

預覽

seperated_usage

看代碼可以知道不同點就在於實現了separatorBuilder這個函數,通過它我們可以自定義每個子元素之間的分割線了。

2.4 小結

到目前爲止,我們一共學習了ListViewListView.buildListView.seperated三種創建列表的方式,它們各自都有其適用的場景,所以遇到需求時還是得具體問題具體分析。

不過,其實ListView還有一個構造函數:ListView.custom。而且ListView.buildListView.seperated最終都是通過ListView.custom實現的。但是本文並不打算介紹這種方法,因爲一般情況下前面提到的三種構造方法就已經足夠解決問題了(以後遇到實際問題再研究這個)。

3. ListView進階方法

上文我們介紹了ListView的基礎用法,但是在實際的產品中,我們還會遇到列表下拉刷新上拉加載等需求。接下來,就讓我們學習下Flutter中應該如何實現此類交互操作。

3.1 下拉刷新

要在Flutter中實現列表的下拉刷新效果,其實非常簡單,因爲Flutter給我們封裝好了一個RefreshIndicator組件,使用起來也非常方便。看下示例代碼:

class PullDownRefreshList extends StatefulWidget {
  const PullDownRefreshList({Key key}) : super(key: key);

  @override
  _PullDownRefreshListState createState() => _PullDownRefreshListState();
}

class _PullDownRefreshListState extends State<PullDownRefreshList> {

  Future onRefresh() {
    return Future.delayed(Duration(seconds: 1), () {
      Toast.show('當前已是最新數據', context);
    });
  }

  @override
  Widget build(BuildContext context) {
    return RefreshIndicator(
      onRefresh: this.onRefresh,
      child: ListView.separated(
        itemCount: friendListData.length,
        itemBuilder: (context, index) {
          return FriendCard(data: friendListData[index]);
        },
        separatorBuilder: (context, index) {
          return Divider(
            height: .5,
            indent: 75,
            color: Color(0xFFDDDDDD),
          );
        },
      ),
    );
  }
}

由於列表的數據源是可變的,因此這次的組件我們選用繼承自StatefulWidget

可以看到RefreshIndicator的用法十分簡單,只要將我們原來的ListView作爲其child,並且實現其onRefresh方法就好了。而onRefresh方法其實是刷新完畢通知RefreshIndicator的一個回調函數。上述代碼中,我們模擬了一個1s的等待當做網絡請求,然後彈出一個Toast提示"已經是最新數據"(此處的Toast是安裝了toast: ^0.1.3這個包,Flutter原生並沒有提供)。

這裏模仿了今日頭條的列表UI作爲示例(新聞卡片的樣式佈局可以看這裏),我們來看下效果:

pull_down_refresh_usage

可以看到一切都如預期成功執行了,效果還是不錯的,而且RefreshIndicator使用起來也是非常簡便。但是,由於Flutter封裝好的RefreshIndicator組件可定製性有點弱,不太能夠滿足大多數app中自定義樣式的要求。不過好在看了下RefreshIndicator的源碼並不是很多,等日後學了動畫再回頭來研究下如何定製一個自定義的下拉刷新組件。

3.2 上拉加載

除了下拉刷新之外,上拉加載是經常會遇到的另一種列表操作。不過,這次Flutter倒是沒有像下拉刷新那樣提供現成的組件可以直接調用,上拉加載的交互需要我們自己完成。爲此,我們先來簡單分析下:

  1. 組件內部需要一個list變量存儲當前列表的數據源;
  2. 組件內部需要一個bool型的isLoading標誌位來表示當前是否處於Loading狀態;
  3. 需要能夠判斷出當前列表是否已經滾動到底部,而這就要藉助到我們前面提到過的controller屬性了(ScrollController可以獲取到當前列表的滾動位置以及列表最大滾動區域,相比較即可得到結果);
  4. 當開始加載數據的時候,需要將isLoading置爲true;當數據加載完畢的時候,需要將新的數據合併到list變量中,並且重新將isLoading置爲false

根據上面的思路,我們可以得到下面的代碼:

class PullUpLoadMoreList extends StatefulWidget {
  const PullUpLoadMoreList({Key key}) : super(key: key);

  @override
  _PullUpLoadMoreListState createState() => _PullUpLoadMoreListState();
}

class _PullUpLoadMoreListState extends State<PullUpLoadMoreList> {
  bool isLoading = false;
  ScrollController scrollController = ScrollController();
  List<NewsViewModel> list = List.from(newsList);

  @override
  void initState() {
    super.initState();
    // 給列表滾動添加監聽
    this.scrollController.addListener(() {
      // 滑動到底部的關鍵判斷
      if (
        !this.isLoading &&
        this.scrollController.position.pixels >= this.scrollController.position.maxScrollExtent
      ) {
        // 開始加載數據
        setState(() {
          this.isLoading = true;
          this.loadMoreData();
        });
      }
    });
  }

  @override
  void dispose() {
    // 組件銷燬時,釋放資源(一定不能忘,否則可能會引起內存泄露)
    super.dispose();
    this.scrollController.dispose();
  }

  Future loadMoreData() {
    return Future.delayed(Duration(seconds: 1), () {
      setState(() {
        this.isLoading = false;
        this.list.addAll(newsList);
      });
    });
  }

  Widget renderBottom() {
    // TODO
  }

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      controller: this.scrollController,
      itemCount: this.list.length + 1,
      separatorBuilder: (context, index) {
        return Divider(height: .5, color: Color(0xFFDDDDDD));
      },
      itemBuilder: (context, index) {
        if (index < this.list.length) {
          return NewsCard(data: this.list[index]);
        } else {
          return this.renderBottom();
        }
      },
    );
  }
}

其中有一點需要注意,列表的itemCount值變成了list.length + 1,這是因爲我們多渲染了一個底部組件。當不在加載的時候,我們可以展示一個上拉加載更多的提示性組件;當正在加載數據時,我們又可以展示一個努力加載中...的佔位組件。renderBottom的實現如下:

Widget renderBottom() {
  if(this.isLoading) {
    return Container(
      padding: EdgeInsets.symmetric(vertical: 15),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Text(
            '努力加載中...',
            style: TextStyle(
              fontSize: 15,
              color: Color(0xFF333333),
            ),
          ),
          Padding(padding: EdgeInsets.only(left: 10)),
          SizedBox(
            width: 20,
            height: 20,
            child: CircularProgressIndicator(strokeWidth: 3),
          ),
        ],
      ),
    );
  } else {
    return Container(
      padding: EdgeInsets.symmetric(vertical: 15),
      alignment: Alignment.center,
      child: Text(
        '上拉加載更多',
        style: TextStyle(
          fontSize: 15,
          color: Color(0xFF333333),
        ),
      ),
    );
  }
}

最後,我們再來看下最終的實現效果:

pull_up_load_more_usage

4. 總結

首先,本文介紹了常用的ListViewListView.buildListView.seperated三種構造方法來創建列表,並結合實際的例子加以說明其不同的使用場景。緊接着,又介紹了列表組件下拉刷新上拉加載這兩個較常用到的交互操作在Flutter中應該如何實現。

通過文中的5個實際例子,相信你一定已經對Flutter中如何使用ListView有了初步瞭解,剩下的就是多練習(盤它)咯~

本文所有代碼託管在這兒,也可以關注我的Blog

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章