Flutter - Listview 詳解

前行的烏龜發表於2019-12-12

Flutter - Listview 詳解

未完~

列表到哪裡都是一個麻煩的東西,因為現在開發出來的玩法太多啦,像:基本的多型別 item介面卡設計複雜item 檢視設計item 更新粒度層次優化列表快取設計原理和優化列表顯示優化列表巢狀滑動衝突巢狀滾動和其他 view 的聯動 等等方面,不管到哪個平臺都得搞懂這些才能玩的轉

Flutter 中的列表我個人覺得比 Android 設計的更好一些,首先名字迴歸 listview,見名知已,簡單明瞭。其次 Flutter 使用 build 介面來代替 adapter,並且分隔線之類的也看做 item 使用 build來處理,這就比 android 好寫,好明白多了,也更靈活,對列表本身的破壞更小


4種構建方式

Flutter 的 listview 有4種構建方式,也就是有4個 構造方法,核心思路就是把生成 item 的部分看成一個 物件函式(各種build) 傳進構造方法中。 Flutter 的 listview 真的喜歡 build 設計模式,不光 item,分割線,多型別都是包裝成 build

  • listview - 這種構建方式是把固定資料變成 item 傳給 childen
  Widget build(BuildContext context) {
    // TODO: implement build
    return ListView(
      padding: EdgeInsets.all(20),
       children: <Widget>[
         Text("AA"),
         Text("BB"),
         Text("CC"),
       ],
    );
  }
複製程式碼
  • ListView.builder - 構建一種 item 型別的列表,當然在 itemBuilder 使用 if、slse 也是能支援多型別 item 的,itemCount 是列表數量
  Widget build(BuildContext context) {
    // TODO: implement build
    return ListView.builder(
      padding: EdgeInsets.all(20),
      itemCount: 50,
      itemBuilder: (context, index) {
        return Text("item:${index}");
      },
    );
  }
複製程式碼
  • ListView.separated - 帶分割線的列表, Flutter中把分割線也是看成一個 widget 來處理的,這裡新增了 separatorBuilder 來返回分割線 widget,不過分割線的特點是:不包含最後一項,也就是分割線不會出列表的範圍。這中設計我很喜歡,把分割線或是分隔符看成列表的 item,可以極大的方便我們設計列表,可玩性靈活性可以大大增加,至少我們可以根據 index 找到前後 item 的型別,然後考慮可以採取不同型別的分隔 item,最典型的應用就是插入廣告了,不用對列表有任何修改
  Widget build(BuildContext context) {
    // TODO: implement build
    return ListView.separated(
      padding: EdgeInsets.all(20),
      itemCount: 50,
      itemBuilder: (context, index) {
        return Text("item:${index}");
      },
      separatorBuilder: (context,index){
        return Container(
          height: 1,
          color: Colors.pink,
        );
      },
    );
  }
複製程式碼
  • ListView.custom() - 需要傳入一個實現了 SliverChildDelegate 的元件,如 SliverChildListDelegate 和 SliverChildBuilderDelegate。這裡我不詳細說,因為沒必要,SliverChildListDelegate 就是 listview,SliverChildBuilderDelegate 就是 ListView.builder。這個 ListView.custom 用的也比較少

常用屬性

  • ScrollDirection - 滾動方向,支援橫向滾動,預設是縱向滾動
scrollDirection: Axis.horizontal,
複製程式碼
  • reverse - 決定滾動方向是否與閱讀方向一致,true 表示不一樣,顯示時是列表直接滾動到最底下,最後一個 item 顯示第一個資料
    Flutter - Listview 詳解
  • scrollController - 主要作用是控制滾動位置和監聽滾動事件,下面我會單開一節詳細的解釋下
  • primary - 當內容不足以滾動時,是否支援滾動;對於iOS系統還有一個效果:當使用者點選狀態列時是否滑動到頂部。這個挺重要的,尤其是使用列表構建頁面時配合下拉重新整理,要是資料量小於螢幕高度的話,這個就管用了。此時系統會自動給 listview 設定一個預設的滾動控制器 PrimaryScrollController,好處是父元件可以控制子樹中可滾動元件的滾動行為
  • itemExtent - 可以直接設定列表項高度,可以提高列表效能

scrollController 滾動控制

大家一看這個名字裡帶 Controller 那就說明是對列表進行控制的功能的。scrollController 主要功能就是監聽滾動狀態,提供方法滾動到列表指定位置

1. scrollController 建構函式

ScrollController({
  double initialScrollOffset = 0.0, //初始滾動位置
  this.keepScrollOffset = true,//是否儲存滾動位置
  ...
})
複製程式碼

scrollController 需要我們 new 一個物件出來,通過建構函式中的這2個引數,我們可以設定選擇列表從哪裡開始顯示,配合 itemExtent 每列固定高度設定是個不錯的思路

2. 核心引數、方法:

  • offset - 當前滾動到的位置,注意這個資料是累計值,不是每次滾動的量
  • jumpTo(double offset) - 滾動到指定位置,不帶動畫
  • animateTo(double offset,...) - 滾動到指定位置,帶動畫,可以指定時間

3. 使用

  • 先 new 一個 scrollController 物件出來
  • 在 initState 中給 scrollController 新增監聽
  • 在 佈局中把 scrollController 設定給 listview
class TestWidgetState extends State<TestWidget> {
  var scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    scrollController.addListener(() {
      print("當前滾動位置:${scrollController.offset}");
    });
  }

  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return ListView.builder(
      padding: EdgeInsets.all(20),
      physics: BouncingScrollPhysics(),
      itemCount: 50,
      controller: scrollController,
      itemBuilder: (context, index) {
        return Container(
          width: 50,
          height: 30,
          alignment: Alignment.center,
          child: Text("item:${index}"),
        );
      },
    );
  }
  
  void dispose() {
    //為了避免記憶體洩露,需要呼叫 dispose
    scrollController.dispose();
    super.dispose();
  }
}
複製程式碼

4. 特點

  • scrollController 可以新增多個 Listener 呢的,從其方法 scrollController.addListener 就能看的出來,走進原始碼中 _listeners 是一個集合型別
ObserverList<VoidCallback> _listeners = ObserverList<VoidCallback>();
複製程式碼
  • scrollController.offset 滾動的數值是從 0 開始的,可見和 Android 一樣,滾動數值,可滾動的最大數值,螢幕可見高度 這3者是相互分開的
I/flutter ( 7435): 當前滾動位置:1.1666666666666667
I/flutter ( 7435): 當前滾動位置:2.621212121212163
I/flutter ( 7435): 當前滾動位置:3.7121212121212848
I/flutter ( 7435): 當前滾動位置:4.803030303030293
I/flutter ( 7435): 當前滾動位置:5.530303030303041
I/flutter ( 7435): 當前滾動位置:6.621212121212163
I/flutter ( 7435): 當前滾動位置:7.348484848484911
I/flutter ( 7435): 當前滾動位置:8.07575757575766
複製程式碼

5. jumpTo、animateTo

這2個方法都是指定列表滾動帶指定位置,目前只能滾動到指定 px,正在研究沒有沒方法指定到指定 index 的 item

  • jumpTo - 這個方法好說,只有一個 offset 的引數就完了
scrollController.jumpTo(500);
複製程式碼
  • animateTo - 這可就不一樣了,是帶動畫的,可以指定時間,插值器,不過這2者要設定必須一起設定,單個不行
scrollController.animateTo(500,duration: Duration(milliseconds: 300), curve: Curves.ease);
複製程式碼

下面是一個 GIF 演示:

Flutter - Listview 詳解

6. ScrollPosition

ScrollController 可是能設定給多個可滾動元件的,其原理就是 ScrollController 會給每一個設定到其中的可滾動元件生成一個 ScrollPosition,ScrollController positions 屬性儲存這些資料

自然所有關於滾動的資料都儲存在 ScrollPosition 裡面,比如滾動數值:

double get offset => position.pixels;
複製程式碼

但是 offset 這個數值返回的處於顯示的或是最上層位置的滾動元件的滾動資料,ScrollController 若是繫結了多個可滾動元件的話這個 offset 就不準了,並且 jumpTo、animateTo 方法會對 ScrollController 中所有已繫結的可滾動元件進行形同數值的滾動,這點要注意啦

當然我們也不是一點辦法都沒有:

controller.positions.elementAt(0).pixels
controller.positions.elementAt(1).pixels
複製程式碼

這樣可以拿到不通滾動元件的滾動值,但是我們得明確的知道可滾動元件當初的插入順序


NotificationListener 滾動監聽

上文我們用 scrollController 監聽列表滾動,這裡我們還有另一種思路,傳承與 Android 的巢狀滾動思路

Android 中有 NestedScrollingParent、NestedScrollingChild 這一對介面,NestedScrollingChild 傳送滾動事件,NestedScrollingParent 控制滾動事件的傳播和數值

Flutter 集繼承這一思路。在 widget 樹中,可滾動 widget 滾動時會逐次向上傳遞滾動事件 notification,我們可以通過 NotificationListener 可以監控到該 notification,NotificationListener 也是一個 widget,只要 NotificationListener 的佈局層級比 listview 高就行,隔幾層都沒關係,一樣可以監聽的到

  • android 中的經典應用的是:Behavior CoordinatorLayout RecycleView
  • flutter 中則是: NotificationListener listview

1. 監聽方法

NotificationListener 中 onNotification(ScrollNotification notification) 方法可以拿到滾動事件,數值包裹在 ScrollNotification 這個引數總。需要我們返回一個 boolean 值:

  • true - 那麼 notifcation 到此為止,我們攔截了滾動事件,不會繼續上傳滾動事件,但是不影響 listview 自身 widget 的滾動
  • false - 那 麼notification 會繼續向更外層 widget 傳遞

2. 監聽到的資料

滾動事件的資料是 ScrollNotification 型別的,具體引數都在其 metrics 引數中:

  • metrics.pixels - 當前位置,以 0 開始,預設是0
  • metrics.atEdge - 是否在頂部或底部
  • metrics.axis - 垂直或水平滾動
  • metrics.axisDirection - 滾動方向是 down 還是 up,測試了下,都是 down
  • metrics.extentAfter - widget 底部距離列表底部有多大
  • metrics.extentBefore - widget 頂部距離列表頂部有多大
  • metrics.extentInside - widget 範圍內的列表長度
  • metrics.maxScrollExtent - 最大滾動距離,列表長度 - widget 長度
  • metrics.minScrollExtent - 最小滾動距離
  • metrics.viewportDimension - widget 長度
  • metrics.outOfRange - 是否越過邊界

3. 經測試

文件的解釋不一定準,還得我們自己試下才行的

  • axisDirection - 一直都是 down 的,我們還是根據數值的變化自己判斷更準確
  • pixels - 上拉載入更多數值越來越大,下拉重新整理數值越來越小,一直到0
  • atEdge - 的確可以判斷是否到頂或是到底,到頂或是底時,的確會變成 true 的
  • 滾動數值這塊 - extentAfter + extentBefore 的確= maxScrollExtent,說明數值這塊大家不用擔心
  • widget 長度 - viewportDimension 和 extentInside 數值是一樣的
  • outOfRange - 預設到底或是到頂是 false 的,只有我們配合 physics 屬性繼續滾動時才會變成 true,大家注意臨界值

4. 示例:

  Widget build(BuildContext context) {
    return NotificationListener(
      onNotification: (ScrollNotification notification) {
        print("pixels:${notification.metrics.pixels}");
        print("atEdge:${notification.metrics.atEdge}");
        print("axis:${notification.metrics.axis}");
        print("axisDirection:${notification.metrics.axisDirection}");
        print("extentAfter:${notification.metrics.extentAfter}");
        print("extentBefore:${notification.metrics.extentBefore}");
        print("extentInside:${notification.metrics.extentInside}");
        print("maxScrollExtent:${notification.metrics.maxScrollExtent}");
        print("minScrollExtent:${notification.metrics.minScrollExtent}");
        print("viewportDimension:${notification.metrics.viewportDimension}");
        print("outOfRange:${notification.metrics.outOfRange}");
        print("____________________________________________");
        return true;
      },
      child: Container(
        child: Stack(
          children: <Widget>[
            ListView.builder(
              padding: EdgeInsets.all(20),
              physics: BouncingScrollPhysics(),
              itemCount: 50,
              itemExtent: 35,
              controller: scrollController,
              itemBuilder: (context, index) {
                return Container(
                  alignment: Alignment.center,
                  child: Text("item:${index}"),
                );
              },
            ),
            Positioned(
              left: 20,
              top: 20,
              child: RaisedButton(
                child: Text("點選滾動"),
                onPressed: () {
                  scrollController.animateTo(500,
                      duration: Duration(milliseconds: 300),
                      curve: Curves.ease);
                  print("AAA");
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
複製程式碼

滾動資料快取

listview 繼承自:Scrollable,本身是一個 StatefulWidget,自然也能儲存資料,滾動偏移量自然也能儲存,所以主要 listview 不從 widget 樹上移出,那麼滾動狀態一直都在

但是有的時候因 widget 樹的變化 listview 會被移除,比如:TabBarView 在切換 Tab 時可滾動元件的 State 會被銷燬,此時我們要是想要在 tab 來回切換時能顯示上次滾動到的位置,那麼必須能快取滾動偏移量才行

這裡我們藉助 PageStorage,他是一個用於儲存頁面(路由)相關資料的元件,PageStorage 的宣告週期是整個 app,不管有多少頁面都可以儲存資料不丟失,核心就是通過設定 PageStorageKey 到 wigdet 建構函式的 可以就行

我們給每個 tab 設定自己字串的 PageStorageKey 就成了

new TabBarView(
   children: myTabs.map((Tab tab) {
    new MyScrollableTabView(
      key: new PageStorageKey<String>(tab.text), // like 'Tab 1'
       tab: tab,
     ),
   }),
 )
複製程式碼

目前這塊就這麼多


常用設計思路

  • ListView.separated 插入廣告
    Flutter - Listview 詳解

相關文章