Flutter滾動Widget

列表是移動端經常使用的一種視圖展示方式,在Flutter中提供了ListView和GridView。

一、ListView組件

移動端數據量比較大時,我們都是通過列表來進行展示的,比如商品數據、聊天列表、通信錄、朋友圈等。在iOS中,我們可以通過UITableView來實現。
ListView可以沿一個方向(垂直或水平方向,默認是垂直方向)來排列其所有子Widget。
一種最簡單的使用方式是直接將所有需要排列的子Widget放在ListView的children屬性中即可。

1、ListView的使用。

為了讓文字之間有一些間距,我使用了Padding Widget。

class MyHomeBody extends StatelessWidget {
  final TextStyle textStyle = TextStyle(fontSize: 20, color: Colors.redAccent);

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

2、ListTile的使用

使用場景:在開發中,我們經常見到一種列表,有一個圖標或圖片(Icon),有一個標題(Title),有一個子標題(Subtitle),還有尾部一個圖標(Icon)。

class MyHomeBody extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return ListView(
      children: <Widget>[
        ListTile(
          leading: Icon(Icons.people, size: 36,),
          title: Text("聯系人"),
          subtitle: Text("聯系人信息"),
          trailing: Icon(Icons.arrow_forward_ios),
        ),
        ListTile(
          leading: Icon(Icons.email, size: 36,),
          title: Text("郵箱"),
          subtitle: Text("郵箱地址信息"),
          trailing: Icon(Icons.arrow_forward_ios),
        ),
        ListTile(
          leading: Icon(Icons.message, size: 36,),
          title: Text("消息"),
          subtitle: Text("消息詳情信息"),
          trailing: Icon(Icons.arrow_forward_ios),
        ),
        ListTile(
          leading: Icon(Icons.map, size: 36,),
          title: Text("地址"),
          subtitle: Text("地址詳情信息"),
          trailing: Icon(Icons.arrow_forward_ios),
        )
      ],
    );
  }
}

我們可以通過設置 scrollDirection 參數來控制視圖的滾動方向。
這里需要注意,我們需要給Container設置width,否則它是沒有寬度的,就不能正常顯示。
或者我們也可以給ListView設置一個itemExtent,該屬性會設置滾動方向上每個item所占據的寬度。

class MyHomeBody extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return ListView(
      scrollDirection: Axis.horizontal,
      itemExtent: 200,
      children: <Widget>[
        Container(color: Colors.red, width: 200),
        Container(color: Colors.green, width: 200),
        Container(color: Colors.blue, width: 200),
        Container(color: Colors.purple, width: 200),
        Container(color: Colors.orange, width: 200),
      ],
    );
  }
}
3、ListView.build
3.1 ListView的問題:

通過構造函數中的children傳入所有的子Widget有一個問題:默認會創建出所有的子Widget。
但是對于用戶來說,一次性構建出所有的Widget并不會有什么差異,但是對于我們的程序來說會產生性能問題,而且會增加首屏的渲染時間。
我們可以ListView.build來構建子Widget,提供性能。

3.2 ListView.build使用場景:

ListView.build適用于子Widget比較多的場景,該構造函數將創建子Widget交給了一個抽象的方法,交給ListView進行管理,ListView會在真正需要的時候去創建子Widget,而不是一開始就全部初始化好。
該方法有兩個重要參數:
a、itemBuilder:列表項創建的方法。當列表滾動到對應位置的時候,ListView會自動調用該方法來創建對應的子Widget。類型是IndexedWidgetBuilder,是一個函數類型。
b、itemCount:表示列表項的數量,如果為空,則表示ListView為無限列表。

class MyHomeBody extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: 100,
      itemExtent: 80,
      itemBuilder: (BuildContext context, int index) {
        return ListTile(title: Text("標題$index"), subtitle: Text("詳情內容$index"));
      }
    );
  }
}
3.3 ListView.build動態數據

思考:這個時候是否依然可以使用StatelessWidget:
答案:不可以,因為當前我們的數據是異步加載的,剛開始界面并不會展示數據(沒有數據),后面從JSON中加載出來數據(有數據)后,再次展示加載的數據。
這里是有狀態的變化的,從無數據,到有數據的變化。
這個時候,我們需要使用StatefulWidget來管理組件。

3.4 ListView.separated

ListView.separated可以生成列表項之間的分割器,它除了比ListView.builder多了一個separatorBuilder參數,該參數是一個分割器生成器。
實現奇偶行分割線不同的顏色:

class MySeparatedDemo extends StatelessWidget {
  Divider blueColor = Divider(color: Colors.blue);
  Divider redColor = Divider(color: Colors.red);

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      itemBuilder: (BuildContext context, int index) {
        return ListTile(
          leading: Icon(Icons.people),
          title: Text("聯系人${index+1}"),
          subtitle: Text("聯系人電話${index+1}"),
        );
      },
      separatorBuilder: (BuildContext context, int index) {
        return index % 2 == 0 ? redColor : blueColor;
      },
      itemCount: 100
    );
  }
}

二、GridView組件

GridView用于展示多列的展示,在開發中也非常常見,比如直播App中的主播列表、電商中的商品列表等等。iOS中類似的控件為UICollectionView。

1、GridView構造函數

一種使用GridView的方式就是使用構造函數來創建,和ListView對比有一個特殊的參數:gridDelegate , 它用于控制交叉軸的item數量或者寬度,需要傳入的類型是SliverGridDelegate,但是它是一個抽象類,所以我們需要傳入它的子類:SliverGridDelegateWithFixedCrossAxisCount

SliverGridDelegateWithFixedCrossAxisCount({
  @required double crossAxisCount, // 交叉軸的item個數
  double mainAxisSpacing = 0.0, // 主軸的間距
  double crossAxisSpacing = 0.0, // 交叉軸的間距
  double childAspectRatio = 1.0, // 子Widget的寬高比
})

代碼:

class MyGridCountDemo extends StatelessWidget {

 List<Widget> getGridWidgets() {
   return List.generate(100, (index) {
     return Container(
       color: Colors.purple,
       alignment: Alignment(0, 0),
       child: Text("item$index", style: TextStyle(fontSize: 20, color: Colors.white)),
     );
   });
 }

 @override
 Widget build(BuildContext context) {
   return GridView(
     gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
       crossAxisCount: 3,
       mainAxisSpacing: 10,
       crossAxisSpacing: 10,
       childAspectRatio: 1.0
     ),
     children: getGridWidgets(),
   );
 }
}

SliverGridDelegateWithMaxCrossAxisExtent:

SliverGridDelegateWithMaxCrossAxisExtent({
  double maxCrossAxisExtent, // 交叉軸的item寬度
  double mainAxisSpacing = 0.0, // 主軸的間距
  double crossAxisSpacing = 0.0, // 交叉軸的間距
  double childAspectRatio = 1.0, // 子Widget的寬高比
})

代碼:

class MyGridExtentDemo extends StatelessWidget {

  List<Widget> getGridWidgets() {
    return List.generate(100, (index) {
      return Container(
        color: Colors.purple,
        alignment: Alignment(0, 0),
        child: Text("item$index", style: TextStyle(fontSize: 20, color: Colors.white)),
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    return GridView(
      gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
        maxCrossAxisExtent: 150,
        mainAxisSpacing: 10,
        crossAxisSpacing: 10,
        childAspectRatio: 1.0
      ),
      children: getGridWidgets(),
    );
  }
}

這兩種方式也可以不設置delegate,可以分別使用:GridView.count構造函數和GridView.extent構造函數實現相同的效果。

2、GridView.build

GridView.build和ListView一樣,使用構造函數會一次性創建所有的子Widget,會帶來性能問題,所以我們可以使用GridView.build來交給GridView自己管理需要創建的子Widget。

class _GridViewBuildDemoState extends State<GridViewBuildDemo> {
  List<Anchor> anchors = [];

  @override
  void initState() {
    getAnchors().then((anchors) {
      setState(() {
        this.anchors = anchors;
      });
    });
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: GridView.builder(
        shrinkWrap: true,
        physics: ClampingScrollPhysics(),
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2,
          mainAxisSpacing: 10,
          crossAxisSpacing: 10,
          childAspectRatio: 1.2
        ),
        itemCount: anchors.length,
        itemBuilder: (BuildContext context, int index) {
          return Container(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Image.network(anchors[index].imageUrl),
                SizedBox(height: 5),
                Text(anchors[index].nickname, style: TextStyle(fontSize: 16),),
                Text(anchors[index].roomName, maxLines: 1, overflow: TextOverflow.ellipsis,)
              ],
            ),
          );
        }
      ),
    );
  }
}

三、Sliver

場景:一個滑動的視圖中包括一個標題視圖(HeaderView),一個列表視圖(ListView),一個網格視圖(GridView)。我們怎么可以讓它們做到統一的滑動效果呢?
使用前面的滾動是很難做到的。Flutter中有一個可以完成這樣滾動效果的Widget:CustomScrollView,可以統一管理多個滾動視圖。
在CustomScrollView中,每一個獨立的,可滾動的Widget被稱之為Sliver。
補充:Sliver可以翻譯成裂片、薄片,你可以將每一個獨立的滾動視圖當做一個小裂片。

3.1 Slivers的基本使用

因為我們需要把很多的Sliver放在一個CustomScrollView中,所以CustomScrollView有一個slivers屬性,里面讓我們放對應的一些Sliver:

  • SliverList:類似于我們之前使用過的ListView;
  • SliverFixedExtentList:類似于SliverList只是可以設置滾動的高度;
  • SliverGrid:類似于我們之前使用過的GridView;
  • SliverPadding:設置Sliver的內邊距,因為可能要單獨給Sliver設置內邊距;
  • SliverAppBar:添加一個AppBar,通常用來作為CustomScrollView的HeaderView;
  • SliverSafeArea:設置內容顯示在安全區域(比如不讓齊劉海擋住我們的內容
    SliverGrid+SliverPadding+SliverSafeArea的組合:
class HomeContent extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CustomScrollView(
      slivers: <Widget>[
        SliverSafeArea(
          sliver: SliverPadding(
            padding: EdgeInsets.all(8),
            sliver: SliverGrid(
              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2,
                crossAxisSpacing: 8,
                mainAxisSpacing: 8,
              ),
              delegate: SliverChildBuilderDelegate(
                (BuildContext context, int index) {
                  return Container(
                    alignment: Alignment(0, 0),
                    color: Colors.orange,
                    child: Text("item$index"),
                  );
                },
                childCount: 20
              ),
            ),
          ),
        )
      ],
    );
  }
}
3.2 Slivers的組合使用

SliverAppBar+SliverGrid+SliverFixedExtentList做出如下界面:

class HomeContent extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return showCustomScrollView();
  }

  Widget showCustomScrollView() {
    return new CustomScrollView(
      slivers: <Widget>[
        const SliverAppBar(
          expandedHeight: 250.0,
          flexibleSpace: FlexibleSpaceBar(
            title: Text('Coderwhy Demo'),
            background: Image(
              image: NetworkImage(
                "https://tva1.sinaimg.cn/large/006y8mN6gy1g72j6nk1d4j30u00k0n0j.jpg",
              ),
              fit: BoxFit.cover,
            ),
          ),
        ),
        new SliverGrid(
          gridDelegate: new SliverGridDelegateWithMaxCrossAxisExtent(
            maxCrossAxisExtent: 200.0,
            mainAxisSpacing: 10.0,
            crossAxisSpacing: 10.0,
            childAspectRatio: 4.0,
          ),
          delegate: new SliverChildBuilderDelegate(
                (BuildContext context, int index) {
              return new Container(
                alignment: Alignment.center,
                color: Colors.teal[100 * (index % 9)],
                child: new Text('grid item $index'),
              );
            },
            childCount: 10,
          ),
        ),
        SliverFixedExtentList(
          itemExtent: 50.0,
          delegate: SliverChildBuilderDelegate(
                (BuildContext context, int index) {
              return new Container(
                alignment: Alignment.center,
                color: Colors.lightBlue[100 * (index % 9)],
                child: new Text('list item $index'),
              );
            },
            childCount: 20
          ),
        ),
      ],
    );
  }
}

四、監聽滾動視圖

對于滾動的視圖,我們經常需要監聽它的一些滾動事件,在監聽到的時候去做對應的一些事情。eg:
a、視圖滾動到底部時,我們可能希望做上拉加載更多;
b、滾動到一定位置時顯示一個回到頂部的按鈕,點擊回到頂部的按鈕,回到頂部;
c、監聽滾動什么時候開始,什么時候結束;
在Flutter中監聽滾動相關的內容由兩部分組成:ScrollController和ScrollNotification。

4.1 ScrollController

在Flutter中,Widget并不是最終渲染到屏幕上的元素(真正渲染的是RenderObject),因此通常這種監聽事件以及相關的信息并不能直接從Widget中獲取,而是必須通過對應的Widget的Controller來實現。
ListView、GridView的組件控制器是ScrollController,我們可以通過它來獲取視圖的滾動信息,并且可以調用里面的方法來更新視圖的滾動位置。
另外,通常情況下,我們會根據滾動的位置來改變一些Widget的狀態信息,所以ScrollController通常會和StatefulWidget一起來使用,并且會在其中控制它的初始化、監聽、銷毀等事件。
我們來做一個案例,當滾動到1000位置的時候,顯示一個回到頂部的按鈕:
jumpTo(double offset)、animateTo(double offset,...):這兩個方法用于跳轉到指定的位置,它們不同之處在于,后者在跳轉時會執行一個動畫,而前者不會。
ScrollController間接繼承自Listenable,我們可以根據ScrollController來監聽滾動事件。

class MyHomePage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => MyHomePageState();
}

class MyHomePageState extends State<MyHomePage> {
  ScrollController _controller;
  bool _isShowTop = false;
  
  @override
  void initState() {
    // 初始化ScrollController
    _controller = ScrollController();
    
    // 監聽滾動
    _controller.addListener(() {
      var tempSsShowTop = _controller.offset >= 1000;
      if (tempSsShowTop != _isShowTop) {
        setState(() {
          _isShowTop = tempSsShowTop;
        });
      }
    });
    
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("ListView展示"),
      ),
      body: ListView.builder(
        itemCount: 100,
        itemExtent: 60,
        controller: _controller,
        itemBuilder: (BuildContext context, int index) {
          return ListTile(title: Text("item$index"));
        }
      ),
      floatingActionButton: !_isShowTop ? null : FloatingActionButton(
        child: Icon(Icons.arrow_upward),
        onPressed: () {
          _controller.animateTo(0, duration: Duration(milliseconds: 1000), curve: Curves.ease);
        },
      ),
    );
  }
}
4.2 NotificationListener

場景:監聽什么時候開始滾動,什么時候結束滾動,這個時候我們可以通過NotificationListener。
NotificationListener是一個Widget,模板參數 T 是想監聽的通知類型,如果省略,則所有類型通知都會被監聽,如果指定特定類型,則只有該類型的通知會被監聽。
NotificationListener需要一個onNotification回調函數,用于實現監聽處理邏輯。
該回調可以返回一個布爾值,代表是否阻止該事件繼續向上冒泡,如果為true時,則冒泡終止,事件停止向上傳播,如果不返回或者返回值為false 時,則冒泡繼續。

案例: 列表滾動, 并且在中間顯示滾動進度:

class MyHomeNotificationDemo extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => MyHomeNotificationDemoState();
}

class MyHomeNotificationDemoState extends State<MyHomeNotificationDemo> {
  int _progress = 0;

  @override
  Widget build(BuildContext context) {
    return NotificationListener(
      onNotification: (ScrollNotification notification) {
        // 1.判斷監聽事件的類型
        if (notification is ScrollStartNotification) {
          print("開始滾動.....");
        } else if (notification is ScrollUpdateNotification) {
          // 當前滾動的位置和總長度
          final currentPixel = notification.metrics.pixels;
          final totalPixel = notification.metrics.maxScrollExtent;
          double progress = currentPixel / totalPixel;
          setState(() {
            _progress = (progress * 100).toInt();
          });
          print("正在滾動:${notification.metrics.pixels} - ${notification.metrics.maxScrollExtent}");
        } else if (notification is ScrollEndNotification) {
          print("結束滾動....");
        }
        return false;
      },
      child: Stack(
        alignment: Alignment(.9, .9),
        children: <Widget>[
          ListView.builder(
            itemCount: 100,
            itemExtent: 60,
            itemBuilder: (BuildContext context, int index) {
              return ListTile(title: Text("item$index"));
            }
          ),
          CircleAvatar(
            radius: 30,
            child: Text("$_progress%"),
            backgroundColor: Colors.black54,
          )
        ],
      ),
    );
  }
}

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,825評論 6 546
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,814評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,980評論 0 384
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 64,064評論 1 319
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,779評論 6 414
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,109評論 1 330
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,099評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,287評論 0 291
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,799評論 1 338
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,515評論 3 361
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,750評論 1 375
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,221評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,933評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,327評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,667評論 1 296
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,492評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,703評論 2 380