Flutter滾動型容器元件 - ListView篇

SmallStoneSK發表於2019-07-10

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.separated建構函式建立的列表,不在可視區域和預渲染區域內的子元素不會被建立或會被銷燬);
  • 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),
      ],
    );
  }
}
複製程式碼

預覽

Flutter滾動型容器元件 - ListView篇

可以看到,預設建構函式的用法非常之簡單,直接把子元素元件放在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]);
        },
      ),
    );
  }
}
複製程式碼

預覽

Flutter滾動型容器元件 - ListView篇

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

2.3 ListView.separated()

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

ListView.separated({
  ...
  @required IndexedWidgetBuilder separatorBuilder
})
複製程式碼

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

程式碼(檔案地址

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),
        );
      },
    );
  }
}
複製程式碼

預覽

Flutter滾動型容器元件 - ListView篇

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

2.4 小結

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

不過,其實ListView還有一個建構函式:ListView.custom。而且ListView.buildListView.separated最終都是通過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作為示例(新聞卡片的樣式佈局可以看這裡),我們來看下效果:

Flutter滾動型容器元件 - ListView篇

可以看到一切都如預期成功執行了,效果還是不錯的,而且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),
        ),
      ),
    );
  }
}
複製程式碼

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

Flutter滾動型容器元件 - ListView篇

4. 總結

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

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

本文所有程式碼託管在這兒,也可以關注我的Blog,一起交流學習~

相關文章