Flutter 可滾動(dòng)組件 之 ListView (十六)

約定:后面如果我們說一個(gè)組件是Sliver 則表示它是基于Sliver布局的組件,同理,說一個(gè)組件是 RenderBox,則代表它是基于盒模型布局的組件,并不是說它就是 RenderBox 類的實(shí)例。

ListView是最常用的可滾動(dòng)組件之一,它可以沿一個(gè)方向線性排布所有子組件,并且它也支持列表項(xiàng)懶加載(在需要時(shí)才會(huì)創(chuàng)建)。我們看看ListView的默認(rèn)構(gòu)造函數(shù)定義:

ListView({
  ...  
  //可滾動(dòng)widget公共參數(shù)
  Axis scrollDirection = Axis.vertical,
  bool reverse = false,
  ScrollController? controller,
  bool? primary,
  ScrollPhysics? physics,
  EdgeInsetsGeometry? padding,
  
  //ListView各個(gè)構(gòu)造函數(shù)的共同參數(shù)  
  double? itemExtent,
  Widget? prototypeItem, //列表項(xiàng)原型,后面解釋
  bool shrinkWrap = false,
  bool addAutomaticKeepAlives = true,
  bool addRepaintBoundaries = true,
  double? cacheExtent, // 預(yù)渲染區(qū)域長(zhǎng)度
    
  //子widget列表
  List<Widget> children = const <Widget>[],
})

上面參數(shù)分為兩組:第一組是可滾動(dòng)組件的公共參數(shù),本章第一節(jié)中已經(jīng)介紹過,不再贅述;第二組是ListView各個(gè)構(gòu)造函數(shù)(ListView有多個(gè)構(gòu)造函數(shù))的共同參數(shù),我們重點(diǎn)來看看這些參數(shù),:

  • itemExtent:該參數(shù)如果不為null,則會(huì)強(qiáng)制children的“長(zhǎng)度”為itemExtent的值;這里的“長(zhǎng)度”是指滾動(dòng)方向上子組件的長(zhǎng)度,也就是說如果滾動(dòng)方向是垂直方向,則itemExtent代表子組件的高度;如果滾動(dòng)方向?yàn)樗椒较?,則itemExtent就代表子組件的寬度。在ListView中,指定itemExtent比讓子組件自己決定自身長(zhǎng)度會(huì)有更好的性能,這是因?yàn)橹付╥temExtent后,滾動(dòng)系統(tǒng)可以提前知道列表的長(zhǎng)度,而無需每次構(gòu)建子組件時(shí)都去再計(jì)算一下,尤其是在滾動(dòng)位置頻繁變化時(shí)(滾動(dòng)系統(tǒng)需要頻繁去計(jì)算列表高度)。

  • prototypeItem:如果我們知道列表中的所有列表項(xiàng)長(zhǎng)度都相同但不知道具體是多少,這時(shí)我們可以指定一個(gè)列表項(xiàng),該列表項(xiàng)被稱為 prototypeItem(列表項(xiàng)原型)。指定 prototypeItem 后,可滾動(dòng)組件會(huì)在 layout 時(shí)計(jì)算一次它延主軸方向的長(zhǎng)度,這樣也就預(yù)先知道了所有列表項(xiàng)的延主軸方向的長(zhǎng)度,所以和指定 itemExtent 一樣,指定 prototypeItem 會(huì)有更好的性能。注意,itemExtent 和prototypeItem 互斥,不能同時(shí)指定它們。

  • shrinkWrap:該屬性表示是否根據(jù)子組件的總長(zhǎng)度來設(shè)置ListView的長(zhǎng)度,默認(rèn)值為false 。默認(rèn)情況下,ListView會(huì)在滾動(dòng)方向盡可能多的占用空間。當(dāng)ListView在一個(gè)無邊界(滾動(dòng)方向上)的容器中時(shí),shrinkWrap必須為true。

  • addAutomaticKeepAlives:該屬性我們將在介紹 PageView 組件時(shí)詳細(xì)解釋。

  • addRepaintBoundaries:該屬性表示是否將列表項(xiàng)(子組件)包裹在RepaintBoundary組件中。RepaintBoundary 讀者可以先簡(jiǎn)單理解為它是一個(gè)”繪制邊界“,將列表項(xiàng)包裹在RepaintBoundary中可以避免列表項(xiàng)不必要的重繪,但是當(dāng)列表項(xiàng)重繪的開銷非常?。ㄈ缫粋€(gè)顏色塊,或者一個(gè)較短的文本)時(shí),不添加RepaintBoundary反而會(huì)更高效(具體原因會(huì)在本書后面 Flutter 繪制原理相關(guān)章節(jié)中介紹)。如果列表項(xiàng)自身來維護(hù)是否需要添加繪制邊界組件,則此參數(shù)應(yīng)該指定為 false。

注意:上面這些參數(shù)并非ListView特有,在本章后面介紹的其它可滾動(dòng)組件也可能會(huì)擁有這些參數(shù),它們的含義是相同的。

1. 默認(rèn)構(gòu)造函數(shù)

默認(rèn)構(gòu)造函數(shù)有一個(gè)children參數(shù),它接受一個(gè)Widget列表(List<Widget>)。這種方式適合只有少量的子組件數(shù)量已知且比較少的情況,反之則應(yīng)該使用ListView.builder 按需動(dòng)態(tài)構(gòu)建列表項(xiàng)

注意,雖然這種方式將所有children一次性傳遞給 ListView,但子組件仍然是在需要時(shí)才會(huì)加載(build(如有)、布局、繪制),也就是說通過默認(rèn)構(gòu)造函數(shù)構(gòu)建的 ListView 也是基于 Sliver 的列表懶加載模型。

示例1

class ListViewDemo extends StatelessWidget {
  ListViewDemo({
    Key? key,
  }) : super(key: key);
  
  final TextStyle textStyle = TextStyle(fontSize: 20, color: Colors.redAccent);

  @override
  Widget build(BuildContext context) {
    return ListView(
      shrinkWrap: true,
      children: [
        Padding(
            padding: EdgeInsets.all(8),
            child: Text("人的一切痛苦,本質(zhì)上都是對(duì)自己無能的憤怒。", style: textStyle)),
        Padding(
            padding: EdgeInsets.all(8),
            child: Text("人活在世界上,不可以有偏差;而且多少要費(fèi)點(diǎn)勁兒,才能把自己保持到理性的軌道上。",
                style: textStyle)),
        Padding(
            padding: EdgeInsets.all(8),
            child: Text("我活在世上,無非想要明白些道理,遇見些有趣的事。。", style: textStyle))
      ],
    );
  }
}
image.png

示例2

class ListViewDemo1 extends StatelessWidget {
  const ListViewDemo1({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView(
      children: List.generate(100, (index) {
        // ListTile 小分片
        return ListTile(
          leading: Icon(Icons.people),
          trailing: Icon(Icons.delete),
          title: Text(
            "聯(lián)系人 ${index + 1}",
            style: TextStyle(color: Colors.orange, fontSize: 20),
          ),
          subtitle: Text(
            "聯(lián)系方式: 18826625555",
            style: TextStyle(color: Colors.grey, fontSize: 16),
          ),
        );
      }),
    );
  }
}

image.png

2. ListView.builder

ListView.builder適合列表項(xiàng)比較多或者列表項(xiàng)不確定的情況,下面看一下ListView.builder的核心參數(shù)列表

ListView.builder({
  // ListView公共參數(shù)已省略  
  ...
  required IndexedWidgetBuilder itemBuilder, 
  int itemCount, // item數(shù)量
  ...
})
  • itemBuilder:它是列表項(xiàng)的構(gòu)建器,類型為IndexedWidgetBuilder,返回值為一個(gè)widget。當(dāng)列表滾動(dòng)到具體的index位置時(shí),會(huì)調(diào)用該構(gòu)建器構(gòu)建列表項(xiàng)。
    *itemCount:列表項(xiàng)的數(shù)量,如果為null,則為無限列表。

示例1

class ListViewBuilderDemo extends StatelessWidget {
  const ListViewBuilderDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: 100,
      itemExtent: 50, // 主軸方向高度
      itemBuilder: (BuildContext ctx, int index) {
        return Text("Item ${index + 1}");
      },
    );
  }
}
image.png

3. ListView.separated

ListView.separated可以在生成的列表項(xiàng)之間添加一個(gè)分割組件,它比ListView.builder多了一個(gè)separatorBuilder參數(shù),該參數(shù)是一個(gè)分割組件生成器

示例1

class ListViewSeparatedDemo extends StatelessWidget {
  const ListViewSeparatedDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
        itemBuilder: (BuildContext ctx, int index) {
          return ListTile(
            leading: Icon(Icons.people),
            trailing: Icon(Icons.delete),
            title: Text(
              "聯(lián)系人 ${index + 1}",
              style: TextStyle(fontSize: 20),
            ),
          );
        },
        separatorBuilder: (BuildContext ctx, int index) {
          return Divider(
            height: 20, // Divider 高度,不是線的高度
            thickness: 5, // 線的高度
            color: index % 2 == 0 ? Colors.orange : Colors.blue,
            indent: 16, // 左側(cè)間距
            endIndent: 16, // 右側(cè)間距
          );
        },
        itemCount: 100);
  }
}
image.png

4. ListView.custom

我們看下ListView.custom的定義

  const ListView.custom({
    ...
    required this.childrenDelegate,
    ...
  }) 

ListView.custom 主要是傳一個(gè)SliverChildDelegate代理, SliverChildDelegate是abstract(抽象類),它有兩個(gè)子類SliverChildBuilderDelegate、SliverChildListDelegate

** SliverChildListDelegate**
定義如下:

  SliverChildListDelegate(
    this.children, {
    this.addAutomaticKeepAlives = true,
    this.addRepaintBoundaries = true,
    this.addSemanticIndexes = true,
    this.semanticIndexCallback = _kDefaultSemanticIndexCallback,
    this.semanticIndexOffset = 0,
  })

children 是必傳的,是不是很眼熟,ListView默認(rèn)構(gòu)造函數(shù)里也是傳一個(gè)children,實(shí)際上ListView默認(rèn)構(gòu)造函數(shù)中是通過children創(chuàng)建一個(gè)SliverChildListDelegate的

ListView構(gòu)造函數(shù)處理children源碼如下:

       childrenDelegate = SliverChildListDelegate(
         children,
         addAutomaticKeepAlives: addAutomaticKeepAlives,
         addRepaintBoundaries: addRepaintBoundaries,
         addSemanticIndexes: addSemanticIndexes,
       ),

** SliverChildBuilderDelegate**
定義如下:

  const SliverChildBuilderDelegate(
    this.builder, {
    this.findChildIndexCallback,
    this.childCount,
    this.addAutomaticKeepAlives = true,
    this.addRepaintBoundaries = true,
    this.addSemanticIndexes = true,
    this.semanticIndexCallback = _kDefaultSemanticIndexCallback,
    this.semanticIndexOffset = 0,
  })

builder是必傳的,是個(gè)NullableIndexedWidgetBuilder類型,和IndexedWidgetBuilder類似,ListView.builder源碼中是通過builder創(chuàng)建個(gè)SliverChildBuilderDelegate的

ListView.builder 處理 itemBuilder 源碼如下

       childrenDelegate = SliverChildBuilderDelegate(
         itemBuilder,
         childCount: itemCount,
         addAutomaticKeepAlives: addAutomaticKeepAlives,
         addRepaintBoundaries: addRepaintBoundaries,
         addSemanticIndexes: addSemanticIndexes,
       ),

示例1 - SliverChildListDelegate

class ListViewCustomDemo1 extends StatelessWidget {
  const ListViewCustomDemo1({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView.custom(
      itemExtent: 50, // 高度
      childrenDelegate: SliverChildListDelegate(
        List.generate(100, (index) {
          return ListTile(
            title: Text("商品Item ${index + 1}",
                style: TextStyle(color: Colors.red, fontSize: 18)),
            trailing: Icon(
              Icons.favorite,
              color: Colors.white,
            ),
          );
        }),
      ),
    );
  }
}
image.png

示例2 - SliverChildBuilderDelegate

class ListViewCustomDemo2 extends StatelessWidget {
  const ListViewCustomDemo2({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView.custom(
      itemExtent: 100,
      childrenDelegate:
          SliverChildBuilderDelegate((BuildContext ctx, int index) {
        return Container(
          color: Color.fromARGB(Random().nextInt(256), Random().nextInt(256),
              Random().nextInt(256), Random().nextInt(256)),
        );
      }, childCount: 100),
    );
  }
}
image.png

5. 固定高度列表

默認(rèn)情況下,列表中的Item的高度是隨內(nèi)容自適應(yīng)的。
但給列表指定 itemExtent 或 prototypeItem 會(huì)有更高的性能,所以當(dāng)我們知道列表項(xiàng)的高度都相同時(shí),強(qiáng)烈建議指定 itemExtent 或 prototypeItem

示例1

class ListViewFixedExtentDemo1 extends StatelessWidget {
  const ListViewFixedExtentDemo1({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemExtent: 56,
      // prototypeItem: ListTile(title: Text("Item")),
      itemBuilder: (BuildContext ctx, int index) {
        return ListTile(
          title: Text("Item $index"),
        );
      },
    );
  }
}

自定義個(gè)LayoutLogPrint組件,在布局時(shí)可以打印當(dāng)前上下文中父組件給子組件的約束信息

完整代碼如下

class ListViewFixedExtentDemo2 extends StatelessWidget {
  const ListViewFixedExtentDemo2({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      prototypeItem: ListTile(title: Text("1")),
      // itemExtent: 56,
      itemBuilder: (context, index) {
        //LayoutLogPrint是一個(gè)自定義組件,在布局時(shí)可以打印當(dāng)前上下文中父組件給子組件的約束信息
        return LayoutLogPrint(
          tag: index,
          child: ListTile(title: Text("$index")),
        );
      },
    );
  }
}

class LayoutLogPrint<T> extends StatelessWidget {
  final Widget child;
  final T? tag;
  const LayoutLogPrint({Key? key, required this.child, this.tag})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(builder: (_, constraints) {
      // assert在編譯release版本時(shí)會(huì)被去除
      assert(() {
        print('${tag ?? key ?? child}: $constraints');
        return true;
      }());
      return child;
    });
  }
}

因?yàn)榱斜眄?xiàng)都是一個(gè) ListTile,高度相同,但是我們不知道 ListTile 的高度是多少,所以指定了prototypeItem ,運(yùn)行后,控制臺(tái)打?。?/p>

flutter: 0: BoxConstraints(w=375.0, h=56.0)
flutter: 1: BoxConstraints(w=375.0, h=56.0)
flutter: 2: BoxConstraints(w=375.0, h=56.0)
flutter: 3: BoxConstraints(w=375.0, h=56.0)
...

可見 ListTile 的高度是 56 ,指定itemExtent為56也是可以的,建議優(yōu)先指定原型,這樣的話在列表項(xiàng)布局修改后,仍然可以正常工作(前提是每個(gè)列表項(xiàng)的高度相同)

如果本例中不指定 itemExtent 或 prototypeItem ,我們看看控制臺(tái)日志信息

flutter: 0: BoxConstraints(w=375.0, 0.0<=h<=Infinity)
flutter: 1: BoxConstraints(w=375.0, 0.0<=h<=Infinity)
flutter: 2: BoxConstraints(w=375.0, 0.0<=h<=Infinity)
flutter: 3: BoxConstraints(w=375.0, 0.0<=h<=Infinity)
...

可以發(fā)現(xiàn),列表不知道列表項(xiàng)的具體高度,高度約束變?yōu)?0.0 到 Infinity。

6.列表的原理

ListView 內(nèi)部組合了 Scrollable、Viewport 和 Sliver,需要注意:

    1. ListView 中的列表項(xiàng)組件都是 RenderBox,并不是 Sliver, 這個(gè)一定要注意。
    1. 一個(gè) ListView 中只有一個(gè)Sliver,對(duì)列表項(xiàng)進(jìn)行按需加載的邏輯是 Sliver 中實(shí)現(xiàn)的。
    1. ListView 的 Sliver 默認(rèn)是 SliverList,如果指定了 itemExtent ,則會(huì)使用 SliverFixedExtentList;如果 prototypeItem 屬性不為空,則會(huì)使用 SliverPrototypeExtentList,無論是是哪個(gè),都實(shí)現(xiàn)了子組件的按需加載模型。

我們解釋下ListView的Sliver
ListView 是繼承于BoxScrollView,BoxScrollView繼承于ScrollView,ScrollView繼承于StatelessWidget,因而ScrollView是我們需要讀的最深層級(jí),在ScrollView的方法中,是通過buildSlivers來獲取slivers的,而buildSlivers方法在ScrollView中是抽象方法,因而它的子類需要實(shí)現(xiàn)?,F(xiàn)在我們需要看下BoxScrollView中關(guān)于buildSlivers的實(shí)現(xiàn),在BoxScrollView的 buildSlivers方法中是通過buildChildLayout來獲取Sliver,而buildChildLayout在BoxScrollView中也是抽象方法,因而我們?nèi)タ碆oxScrollView子類ListView中buildChildLayout的實(shí)現(xiàn),ListView中buildChildLayout源碼如下

  Widget buildChildLayout(BuildContext context) {
    if (itemExtent != null) {
      return SliverFixedExtentList(
        delegate: childrenDelegate,
        itemExtent: itemExtent!,
      );
    } else if (prototypeItem != null) {
      return SliverPrototypeExtentList(
        delegate: childrenDelegate,
        prototypeItem: prototypeItem!,
      );
    }
    return SliverList(delegate: childrenDelegate);
  }

默認(rèn)情況下 ListView的Sliver是SliverList,在itemExtent不為空時(shí),是SliverFixedExtentList,在prototypeItem不為空時(shí)是SliverPrototypeExtentList

7 實(shí)例:無限加載列表

class _MSHomePageContentState extends State<MSHomePageContent> {
  static const loadingTag = "##loading##";
  var _words = <String>[loadingTag];

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

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
        itemBuilder: (BuildContext ctx, int index) {
          if (_words[index] == loadingTag) {
            if (_words.length <= 100) {
              _retrieveData();
              // 加載時(shí)顯示loading
              return Container(
                padding: EdgeInsets.all(16),
                alignment: Alignment.center,
                child: SizedBox(
                  width: 24,
                  height: 24,
                  child: CircularProgressIndicator(strokeWidth: 2.0),
                ),
              );
            } else {
              // 已經(jīng)加載了100條數(shù)據(jù),不再獲取數(shù)據(jù)。
              return Container(
                alignment: Alignment.center,
                padding: EdgeInsets.all(8),
                child: Text("沒有更多", style: TextStyle(color: Colors.grey)),
              );
            }
          }
          // 顯示單詞列表
          return ListTile(title: Text(_words[index]));
        },
        separatorBuilder: (BuildContext ctx, int index) {
          return Divider(
              color: Colors.amber, thickness: 3, indent: 16, endIndent: 16);
        },
        itemCount: _words.length);
  }

  _retrieveData() {
    Future.delayed(Duration(seconds: 3)).then((value) {
      // 每次生成20對(duì)單詞
      List<String> newData =
          generateWordPairs().take(20).map((e) => e.asSnakeCase).toList();
      _words.insertAll(_words.length - 1, newData);

      setState(() {});

      // List<WordPair> newData = generateWordPairs().take(10).toList();
      // List<String> data = newData.map((e) => e.asPascalCase).toList();
      // _words.addAll(data);
    });
  }
}


image.png
?著作權(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)容