首頁 > 軟體

Flutter捲動元件之ListView使用方法詳解

2022-03-22 19:01:16

ListView

ListView是最常用的可捲動元件之一,它可以沿一個方向線性排布所有子元件,並且它也支援基於Sliver的延遲構建模型。我們看看ListView的預設建構函式定義:

ListView({
  ...  
  //可捲動widget公共引數
  Axis scrollDirection = Axis.vertical,
  bool reverse = false,
  ScrollController controller,
  bool primary,
  ScrollPhysics physics,
  EdgeInsetsGeometry padding,

  //ListView各個建構函式的共同引數  
  double itemExtent,
  bool shrinkWrap = false,
  bool addAutomaticKeepAlives = true,
  bool addRepaintBoundaries = true,
  double cacheExtent,

  //子widget列表
  List<Widget> children = const <Widget>[],
})

上面引數分為兩組:第一組是可捲動元件的公共引數,前面已經介紹過,不再贅述;第二組是ListView各個建構函式(ListView有多個建構函式)的共同引數,我們重點來看看這些引數,:

  • itemExtent:該引數如果不為null,則會強制children的“長度”為itemExtent的值;這裡的“長度”是指捲動方向上子元件的長度,也就是說如果捲動方向是垂直方向,則itemExtent代表子元件的高度;如果捲動方向為水平方向,則itemExtent就代表子元件的寬度。在ListView中,指定itemExtent比讓子元件自己決定自身長度會更高效,這是因為指定itemExtent後,捲動系統可以提前知道列表的長度,而無需每次構建子元件時都去再計算一下,尤其是在捲動位置頻繁變化時(捲動系統需要頻繁去計算列表高度)。
  • shrinkWrap:該屬性表示是否根據子元件的總長度來設定ListView的長度,預設值為false。預設情況下,ListView的會在捲動方向儘可能多的佔用空間。當ListView在一個無邊界(捲動方向上)的容器中時,shrinkWrap必須為true。
  • addAutomaticKeepAlives:該屬性表示是否將列表項(子元件)包裹在AutomaticKeepAlive元件中;典型地,在一個懶載入列表中,如果將列表項包裹在AutomaticKeepAlive中,在該列表項滑出視口時它也不會被GC(垃圾回收),它會使用KeepAliveNotification來儲存其狀態。如果列表項自己維護其KeepAlive狀態,那麼此引數必須置為false。
  • addRepaintBoundaries:該屬性表示是否將列表項(子元件)包裹在RepaintBoundary元件中。當可捲動元件捲動時,將列表項包裹在RepaintBoundary中可以避免列表項重繪,但是當列表項重繪的開銷非常小(如一個顏色塊,或者一個較短的文字)時,不新增RepaintBoundary反而會更高效。和addAutomaticKeepAlive一樣,如果列表項自己維護其KeepAlive狀態,那麼此引數必須置為false。

注意:上面這些引數並非ListView特有,其它可捲動元件也可能會擁有這些引數,它們的含義是相同的。

預設建構函式

預設建構函式有一個children引數,它接受一個Widget列表(List)。這種方式適合只有少量的子元件的情況,因為這種方式需要將所有children都提前建立好(這需要做大量工作),而不是等到子widget真正顯示的時候再建立,也就是說通過預設建構函式構建的ListView沒有應用基於Sliver的懶載入模型。實際上通過此方式建立的ListView和使用SingleChildScrollView+Column的方式沒有本質的區別。下面是一個例子:

ListView(
  shrinkWrap: true, 
  padding: const EdgeInsets.all(20.0),
  children: <Widget>[
    const Text('I'm dedicating every day to you'),
    const Text('Domestic life was never quite my style'),
    const Text('When you smile, you knock me out, I fall apart'),
    const Text('And I thought I was so smart'),
  ],
);

再次強調,可捲動元件通過一個List來作為其children屬性時,只適用於子元件較少的情況,這是一個通用規律,並非ListView自己的特性,像GridView也是如此。

ListView.builder

ListView.builder適合列表項比較多(或者無限)的情況,因為只有當子元件真正顯示的時候才會被建立,也就說通過該建構函式建立的ListView是支援基於Sliver的懶載入模型的。下面看一下ListView.builder的核心參數列:

ListView.builder({
  // ListView公共引數已省略  
  ...
  @required IndexedWidgetBuilder itemBuilder,
  int itemCount,
  ...
})
  • itemBuilder:它是列表項的構建器,型別為IndexedWidgetBuilder,返回值為一個widget。當列表捲動到具體的index位置時,會呼叫該構建器構建列表項。
  • itemCount:列表項的數量,如果為null,則為無限列表。

可捲動元件的建構函式如果需要一個列表項Builder,那麼通過該建構函式構建的可捲動元件通常就是支援基於Sliver的懶載入模型的,反之則不支援,這是個一般規律。我們在後面在介紹可捲動元件的建構函式時將不再專門說明其是否支援基於Sliver的懶載入模型了。
下面看一個例子:

ListView.builder(
    itemCount: 100,
    itemExtent: 50.0, //強制高度為50.0
    itemBuilder: (BuildContext context, int index) {
      return ListTile(title: Text("$index"));
    }
);

執行效果如圖所示:

ListView.separated

ListView.separated可以在生成的列表項之間新增一個分割元件,它比ListView.builder多了一個separatorBuilder引數,該引數是一個分割元件生成器。

下面我們看一個例子:奇數行新增一條藍色下劃線,偶數行新增一條綠色下劃線。

class ListView3 extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    //下劃線widget預定義以供複用。  
    Widget divider1=Divider(color: Colors.blue,);
    Widget divider2=Divider(color: Colors.green);
    return ListView.separated(
        itemCount: 100,
        //列表項構造器
        itemBuilder: (BuildContext context, int index) {
          return ListTile(title: Text("$index"));
        },
        //分割器構造器
        separatorBuilder: (BuildContext context, int index) {
          return index%2==0?divider1:divider2;
        },
    );
  }
}

執行效果如圖:

範例:無限載入列表

假設我們要從資料來源非同步分批拉取一些資料,然後用ListView展示,當我們滑動到列表末尾時,判斷是否需要再去拉取資料,如果是,則去拉取,拉取過程中在表尾顯示一個loading,拉取成功後將資料插入列表;如果不需要再去拉取,則在表尾提示"沒有更多"。程式碼如下:

class InfiniteListView extends StatefulWidget {
  @override
  _InfiniteListViewState createState() => new _InfiniteListViewState();
}

class _InfiniteListViewState extends State<InfiniteListView> {
  static const loadingTag = "##loading##"; //表尾標記
  var _words = <String>[loadingTag];

  @override
  void initState() {
    super.initState();
    _retrieveData();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      itemCount: _words.length,
      itemBuilder: (context, index) {
        //如果到了表尾
        if (_words[index] == loadingTag) {
          //不足100條,繼續獲取資料
          if (_words.length - 1 < 100) {
            //獲取資料
            _retrieveData();
            //載入時顯示loading
            return Container(
              padding: const EdgeInsets.all(16.0),
              alignment: Alignment.center,
              child: SizedBox(
                  width: 24.0,
                  height: 24.0,
                  child: CircularProgressIndicator(strokeWidth: 2.0)
              ),
            );
          } else {
            //已經載入了100條資料,不再獲取資料。
            return Container(
                alignment: Alignment.center,
                padding: EdgeInsets.all(16.0),
                child: Text("沒有更多了", style: TextStyle(color: Colors.grey),)
            );
          }
        }
        //顯示單詞列表項
        return ListTile(title: Text(_words[index]));
      },
      separatorBuilder: (context, index) => Divider(height: .0),
    );
  }

  void _retrieveData() {
    Future.delayed(Duration(seconds: 2)).then((e) {
      _words.insertAll(_words.length - 1,
          //每次生成20個單詞
          generateWordPairs().take(20).map((e) => e.asPascalCase).toList()
      );
      setState(() {
        //重新構建列表
      });
    });
  }

}

執行後效果如圖所示:

程式碼比較簡單,可以參照程式碼中的註釋理解。需要說明的是,_retrieveData()的功能是模擬從資料來源非同步獲取資料,我們使用english_words包的generateWordPairs()方法每次生成20個單詞。

新增固定列表頭

很多時候我們需要給列表新增一個固定表頭,比如我們想實現一個商品列表,需要在列表頂部新增一個“商品列表”標題,期望的效果如圖所示:

我們按照之前經驗,寫出如下程式碼:

@override
Widget build(BuildContext context) {
  return Column(children: <Widget>[
    ListTile(title:Text("商品列表")),
    ListView.builder(itemBuilder: (BuildContext context, int index) {
        return ListTile(title: Text("$index"));
    }),
  ]);
}

然後執行,發現並沒有出現我們期望的效果,相反觸發了一個異常;

Error caught by rendering library, thrown during performResize()。
Vertical viewport was given unbounded height ...

從異常資訊中我們可以看到是因為ListView高度邊界無法確定引起,所以解決的辦法也很明顯,我們需要給ListView指定邊界,我們通過SizedBox指定一個列表高度看看是否生效:

... //省略無關程式碼
SizedBox(
    height: 400, //指定列表高度為400
    child: ListView.builder(itemBuilder: (BuildContext context, int index) {
        return ListTile(title: Text("$index"));
    }),
),
...

執行效果如圖所示:

可以看到,現在沒有觸發異常並且列表已經顯示出來了,但是我們的手機螢幕高度要大於400,所以底部會有一些空白。那如果我們要實現列表鋪滿除表頭以外的螢幕空間應該怎麼做?直觀的方法是我們去動態計算,用螢幕高度減去狀態列、導航欄、表頭的高度即為剩餘螢幕高度,程式碼如下:

... //省略無關程式碼
SizedBox(
  //Material設計規範中狀態列、導航欄、ListTile高度分別為24、56、56 
  height: MediaQuery.of(context).size.height-24-56-56,
  child: ListView.builder(itemBuilder: (BuildContext context, int index) {
    return ListTile(title: Text("$index"));
  }),
)
...

執行效果如下圖所示:

可以看到,我們期望的效果實現了,但是這種方法並不優雅,如果頁面佈局發生變化,比如表頭佈局調整導致表頭高度改變,那麼剩餘空間的高度就得重新計算。那麼有什麼方法可以自動拉伸ListView以填充螢幕剩餘空間的方法嗎?當然有!答案就是Flex。前面已經介紹過在彈性佈局中,可以使用Expanded自動拉伸元件大小,並且我們也說過Column是繼承自Flex的,所以我們可以直接使用Column+Expanded來實現,程式碼如下:

@override
Widget build(BuildContext context) {
  return Column(children: <Widget>[
    ListTile(title:Text("商品列表")),
    Expanded(
      child: ListView.builder(itemBuilder: (BuildContext context, int index) {
        return ListTile(title: Text("$index"));
      }),
    ),
  ]);
}

執行後,和上圖一樣,完美實現了!

總結:

本節主要介紹了ListView的一些公共引數以及常用的建構函式。不同的建構函式對應了不同的列表項生成模型,如果需要自定義列表項生成模型,可以通過ListView.custom來自定義,它需要實現一個SliverChildDelegate用來給ListView生成列表項元件,更多詳情請參考API檔案。

以上就是本文的全部內容,希望對大家的學習有所幫助,也希望大家多多支援it145.com。


IT145.com E-mail:sddin#qq.com