Flutter學習小計:ListView的下拉刷新和上拉加載

前言
最近Google開源的跨平臺移動開發框架Flutter非?;馃?,推出了1.0的正式版,趁著熱度,我也是抽空粗略地學習了一下。目前網上Flutter相關的資料和開源項目也非常多了,在學習的過程中給了我很多幫助。因此,我想通過一系列文章記錄一下自己學習Flutter遇到的一些問題,既是對自身技術的鞏固,也方便日后即時查閱。

本文介紹一下列表的下拉刷新和上拉加載,作為移動端最常見的場景之一,在Flutter中是怎樣實現的呢?

1.下拉刷新

和Android原生開發中的SwipeRefreshLayout效果相似,Flutter中也提供了一個Material風格的下拉刷新組件RefreshIndicator,用于實現下拉刷新功能。
構造方法如下:

const RefreshIndicator({
    Key key,
    @required this.child,
    this.displacement = 40.0, // 下拉距離
    @required this.onRefresh, // 刷新回調方法,返回類型必須為Future
    this.color, // 刷新進度條顏色,默認當前主題顏色
    this.backgroundColor, // 背景顏色
    this.notificationPredicate = defaultScrollNotificationPredicate,
    this.semanticsLabel,
    this.semanticsValue,
  }) 

使用時,我們需要用RefreshIndicator去包裹ListView,并指定下拉刷新回調方法onRefresh,完整代碼如下:

import 'package:flutter/material.dart';

class ListViewPage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _ListViewPageState();
  }
}

class _ListViewPageState extends State<ListViewPage> {
  // ListView數據集合
  List<String> _list = List.generate(20, (i) => '原始數據${i + 1}');

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('列表的下拉刷新和上拉加載'),
      ),
      body: Container(
        child: RefreshIndicator(
          child: ListView.builder(
            itemBuilder: (context, index) => ListTile(
                  title: Text(_list[index]),
                ),
            itemCount: _list.length,
          ),
          onRefresh: _handleRefresh,
        ),
      ),
    );
  }

  // 下拉刷新方法
  Future<Null> _handleRefresh() async {
    // 模擬數據的延遲加載
    await Future.delayed(Duration(seconds: 2), () {
      setState(() {
        // 在列表開頭添加幾條數據
        List<String> _refreshData = List.generate(5, (i) => '下拉刷新數據${i + 1}');
        _list.insertAll(0, _refreshData);
      });
    });
  }
}

這里定義了一個下拉刷新回調方法_handleRefresh(),每次下拉刷新都會調用該方法,在該方法中利用Future.delayed()模擬網絡請求延遲加載數據。需要注意,該方法的返回值必須是Future類型。

// 下拉刷新方法
Future<Null> _handleRefresh() async {
  // 模擬數據的延遲加載
  await Future.delayed(Duration(seconds: 2), () {
    setState(() {
      // 在列表開頭添加幾條數據
      List<String> _refreshData = List.generate(5, (i) => '下拉刷新數據${i + 1}');
      _list.insertAll(0, _refreshData);
    });
  });
}

這樣,一個簡單的列表下拉刷新的效果就實現了。


我們在實際開發中有一種場景是:進入頁面自動請求數據并顯示加載進度圈,可以通過在根Widget中添加一個顯示加載進度的組件(比如ProgressIndicator),加載數據前后動態顯示和隱藏該組件來實現。但是既然我們已經使用了RefreshIndicator,可不可以直接利用它的下拉刷新進度圈呢?當然是可以的,這時候就需要利用RefreshIndicatorState了。
RefreshIndicator是一個StatefulWidget,它的State由RefreshIndicatorState管理,我們可以通過RefreshIndicatorState來改變RefreshIndicator的狀態,實現利用代碼動態顯示刷新進度圈。使用時需要使用GlobalKey對RefreshIndicatorState進行管理(我也不知道這樣說是否準確。。。),需要顯示刷新進度圈時再調用RefreshIndicatorState的show()方法即可,完整代碼如下:

import 'package:flutter/material.dart';

class ListViewPage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _ListViewPageState();
  }
}

class _ListViewPageState extends State<ListViewPage> {
  // ListView數據集合
  List<String> _list = new List();

  final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey =
      GlobalKey<RefreshIndicatorState>();

  @override
  void initState() {
    super.initState();
    // 顯示加載進度圈
    _showRefreshLoading();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('列表的下拉刷新和上拉加載'),
      ),
      body: Container(
        child: RefreshIndicator(
          key: _refreshIndicatorKey,
          child: ListView.builder(
            itemBuilder: (context, index) => ListTile(
                  title: Text(_list[index]),
                ),
            itemCount: _list.length,
          ),
          onRefresh: _handleRefresh,
        ),
      ),
    );
  }

  // 顯示加載進度圈
  _showRefreshLoading() {
    // 這里使用延時操作是由于在執行刷新操作時_refreshIndicatorKey還未與RefreshIndicator關聯
    Future.delayed(const Duration(seconds: 0), () {
      _refreshIndicatorKey.currentState.show();
    });
  }

  // 下拉刷新方法
  Future<Null> _handleRefresh() async {
    // 模擬數據的延遲加載
    await Future.delayed(Duration(seconds: 2), () {
      setState(() {
        // 在列表開頭添加幾條數據
        List<String> _refreshData = List.generate(5, (i) => '下拉刷新數據${i + 1}');
        _list.insertAll(0, _refreshData);
      });
    });
  }
}

initState()中執行了_showRefreshLoading()方法,需要注意由于initState()是在build()之前調用的,此時_refreshIndicatorKey還沒有和Widget關聯,直接調用_refreshIndicatorKey.currentState.show()會報錯。解決方法就是通過Future.delay,設置延遲時間為0,保證執行show()方法時RefreshIndicatorState已經被賦值。調用show()之后會自動調用onRefresh指定的回調方法_handleRefresh(),同時顯示加載進度圈,整個刷新效果看著還是比較自然的。

2.上拉加載

相比于下拉刷新,上拉加載的實現要相對麻煩一些。我查閱了一下網上的資料,實現上拉加載可以有兩種方式:第一種是通過指定ListView的controller屬性,類型是ScrollController,通過ScrollController可以判斷ListView是否滑動到了底部,再進行上拉加載的處理;第二種是利用NotificationListener,監聽ListVIew的滑動狀態,當ListView滑動到底部時,進行上拉加載處理。

方法一 利用ScrollController實現上拉加載更多

ListView有一個controller屬性,類型是ScrollController,通過ScrollController可以控制ListView的滑動狀態,判斷ListVIew是否滑動到了底部。判斷的方式如下:

ScrollController _scrollController;

@override
void initState() {
  super.initState();
  // 初始化ScrollController
  _scrollController = ScrollController();
  // 監聽ListView是否滾動到底部
  _scrollController.addListener(() {
    if (_scrollController.position.pixels >=
        _scrollController.position.maxScrollExtent) {
      // 滑動到了底部
      print('滑動到了底部');
      // 這里可以執行上拉加載邏輯
      _loadMore();  
    }
  });
}

@override
void dispose() {
  super.dispose();
  // 這里不要忘了將監聽移除 
  _scrollController.dispose();
}

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: Text('列表的下拉刷新和上拉加載'),
    ),
    body: Container(
      child: RefreshIndicator(
        child: ListView.builder(
          itemBuilder: (context, index) => ListTile(
                title: Text(_list[index]),
              ),
          itemCount: _list.length,
          controller: _scrollController,
        ),
        onRefresh: _handleRefresh,
      ),
    ),
  );
}

_scrollController.position.pixels表示ListView當前滑動的距離,_scrollController.position.maxScrollExtent表示ListView可以滑動的最大距離,因此pixels >= maxScrollExtent就表示ListView已經滑動到了底部,這時執行加載更多的邏輯即可,這里依然是用Future.delayed()來模擬數據的延遲加載。當然不要忘記在dispose()方法中調用_scrollController.dispose()來移除監聽,防止內存泄漏。

// 上拉加載
Future<Null> _loadMore() async {
  // 模擬數據的延遲加載
  await Future.delayed(Duration(seconds: 2), () {
    setState(() {
      List<String> _loadMoreData = List.generate(5, (i) => '上拉加載數據${i + 1}');
      _list.addAll(_loadMoreData);
    });
  });
}

這里還有一個小問題,就是在加載數據的過程中,繼續上滑列表有可能會重復執行加載更多方法,為什么我說是有可能呢?加載更多方法是在滑動監聽中通過判斷執行的,也就是說如果我們在新數據還未加載出來時繼續上滑列表,如果沒有產生滑動偏移量,就不會執行addListener中的聲明的邏輯;但是如果上滑的過程中產生了偏移量(哈哈,說不定你手滑了呢),就會進入到監聽方法中,導致重復執行加載更多方法。解決這個問題的方法很簡單,我們只需要聲明一個變量isLoading來標識是否正在上拉加載就可以了,在加載數據前后更新isLoading的值。

bool isLoading = false; // 是否正在加載,防止多次請求加載下一頁

// 上拉加載
Future<Null> _loadMore() async {
  if (!isLoading) {
    setState(() {
      isLoading = true;
    });
    // 模擬數據的延遲加載
    await Future.delayed(Duration(seconds: 2), () {
      setState(() {
        isLoading = false;
        List<String> _loadMoreData =
            List.generate(5, (i) => '上拉加載數據${i + 1}');
        _list.addAll(_loadMoreData);
      });
    });
  }
}

這樣就實現了列表滑動到底部上拉加載更多數據的效果,目前還有一點需要優化的地方,一般我們在加載更多時會在ListView底部顯示一個加載進度圈,提示用戶此時正在加載數據。實現方法很簡單,就是為ListView添加一個Footer布局。

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: Text('列表的下拉刷新和上拉加載'),
    ),
    body: Container(
      child: RefreshIndicator(
        child: ListView.builder(
          itemBuilder: (context, index) {
            if (index < _list.length) {
              return ListTile(
                title: Text(_list[index]),
              );
            } else {
              // 最后一項,顯示加載更多布局
              return _buildLoadMoreItem();
            }
          },
          itemCount: _list.length + 1,
          controller: _scrollController,
        ),
        onRefresh: _handleRefresh,
      ),
    ),
  );
}

// 加載更多布局
Widget _buildLoadMoreItem() {
  return Center(
    child: Padding(
      padding: EdgeInsets.all(10.0),
      child: Text("加載中..."),
    ),
  );
}

這里為了簡單,只用了一個Text提示用戶正在加載,實際開發中可以根據需求定制自己的加載布局。添加該布局的方法是將ListView的itemCount指定為_list.length + 1,即添加一個item,然后itemBuilder中再根據index判斷是否為最后一項,返回相應的布局就行了。值得一提的是,其實這里也是簡單處理了,加載更多布局始終被添加到列表的最后一項,在實際應用中,我們需要根據具體情況來添加該布局。比如說,當數據集合為空或者數據全部加載完成后,就不需要顯示加載更多布局,還有一種情況是數據沒有填滿整個屏幕時,此時顯示加載更多布局就會很奇怪。
到這里,我們基本上就實現了列表的上拉加載更多。

完整的代碼如下:

import 'package:flutter/material.dart';

class ListViewPage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _ListViewPageState();
  }
}

class _ListViewPageState extends State<ListViewPage> {
  // ListView數據集合
  List<String> _list = List.generate(20, (i) => '原始數據${i + 1}');
  ScrollController _scrollController;
  bool isLoading = false; // 是否正在加載更多

  @override
  void initState() {
    super.initState();
    // 初始化ScrollController
    _scrollController = ScrollController();
    // 監聽ListView是否滾動到底部
    _scrollController.addListener(() {
      if (_scrollController.position.pixels >=
          _scrollController.position.maxScrollExtent) {
        // 滑動到了底部
        print('滑動到了底部');
        // 這里可以執行上拉加載邏輯
        _loadMore();
      }
    });
  }

  @override
  void dispose() {
    super.dispose();
    _scrollController.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('列表的下拉刷新和上拉加載'),
      ),
      body: Container(
        child: RefreshIndicator(
          child: ListView.builder(
            itemBuilder: (context, index) {
              if (index < _list.length) {
                return ListTile(
                  title: Text(_list[index]),
                );
              } else {
                // 最后一項,顯示加載更多布局
                return _buildLoadMoreItem();
              }
            },
            itemCount: _list.length + 1,
            controller: _scrollController,
          ),
          onRefresh: _handleRefresh,
        ),
      ),
    );
  }

  // 加載更多布局
  Widget _buildLoadMoreItem() {
    return Center(
      child: Padding(
        padding: EdgeInsets.all(10.0),
        child: Text("加載中..."),
      ),
    );
  }

  // 下拉刷新方法
  Future<Null> _handleRefresh() async {
    // 模擬數據的延遲加載
    await Future.delayed(Duration(seconds: 2), () {
      setState(() {
        // 在列表開頭添加幾條數據
        List<String> _refreshData = List.generate(5, (i) => '下拉刷新數據${i + 1}');
        _list.insertAll(0, _refreshData);
      });
    });
  }

  // 上拉加載
  Future<Null> _loadMore() async {
    if (!isLoading) {
      setState(() {
        isLoading = true;
      });
      // 模擬數據的延遲加載
      await Future.delayed(Duration(seconds: 2), () {
        setState(() {
          isLoading = false;
          List<String> _loadMoreData =
              List.generate(5, (i) => '上拉加載數據${i + 1}');
          _list.addAll(_loadMoreData);
        });
      });
    }
  }
}

方法二 利用NotificationListener實現上拉加載更多

NotificationListener是一個Widget,可以監聽子Widget發出的Notification。ListView在滑動的過程中會發出ScrollNotification類型的通知,我們可以通過監聽該通知得到ListView的滑動狀態,判斷是否滑動到了底部。NotificationListener有一個onNotification屬性,定義了監聽的回調方法,通過它來處理加載更多邏輯。

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: Text('列表的下拉刷新和上拉加載'),
    ),
    body: Container(
        child: NotificationListener<ScrollNotification>(
      onNotification: (ScrollNotification scrollNotification) {
        if (scrollNotification.metrics.pixels >=
            scrollNotification.metrics.maxScrollExtent) {
          // 滑動到了底部
          // 加載更多
          _loadMore();
        }
        return false;
      },
      child: RefreshIndicator(
        child: ListView.builder(
          itemBuilder: (context, index) {
            if (index < _list.length) {
             return ListTile(
                title: Text(_list[index]),
              );
            } else {
              // 最后一項,顯示加載更多布局
              return _buildLoadMoreItem();
            }
          },
          itemCount: _list.length + 1,
        ),
        onRefresh: _handleRefresh,
      ),
    )),
  );
}

判斷ListView是否滑動到底部的邏輯和方法一相同,依然是通過比較ListView當前滑動的距離和可以滑動的最大距離。加載更多的邏輯也和方法一是一樣的,這里就不多說了。
完整的代碼如下:

import 'package:flutter/material.dart';

class ListViewPage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _ListViewPageState();
  }
}

class _ListViewPageState extends State<ListViewPage> {
  // ListView數據集合
  List<String> _list = List.generate(20, (i) => '原始數據${i + 1}');
  bool isLoading = false; // 是否正在加載更多

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('列表的下拉刷新和上拉加載'),
      ),
      body: Container(
          child: NotificationListener<ScrollNotification>(
        onNotification: (ScrollNotification scrollNotification) {
          if (scrollNotification.metrics.pixels >=
              scrollNotification.metrics.maxScrollExtent) {
            // 滑動到了底部
            // 加載更多
            _loadMore();
          }
          return false;
        },
        child: RefreshIndicator(
          child: ListView.builder(
            itemBuilder: (context, index) {
              if (index < _list.length) {
                return ListTile(
                  title: Text(_list[index]),
                );
              } else {
                // 最后一項,顯示加載更多布局
                return _buildLoadMoreItem();
              }
            },
            itemCount: _list.length + 1,
          ),
          onRefresh: _handleRefresh,
        ),
      )),
    );
  }

  // 加載更多布局
  Widget _buildLoadMoreItem() {
    return Center(
      child: Padding(
        padding: EdgeInsets.all(10.0),
        child: Text("加載中..."),
      ),
    );
  }

  // 下拉刷新方法
  Future<Null> _handleRefresh() async {
    // 模擬數據的延遲加載
    await Future.delayed(Duration(seconds: 2), () {
      setState(() {
        // 在列表開頭添加幾條數據
        List<String> _refreshData = List.generate(5, (i) => '下拉刷新數據${i + 1}');
        _list.insertAll(0, _refreshData);
      });
    });
  }

  // 上拉加載
  Future<Null> _loadMore() async {
    if (!isLoading) {
      setState(() {
        isLoading = true;
      });
      // 模擬數據的延遲加載
      await Future.delayed(Duration(seconds: 2), () {
        setState(() {
          isLoading = false;
          List<String> _loadMoreData =
              List.generate(5, (i) => '上拉加載數據${i + 1}');
          _list.addAll(_loadMoreData);
        });
      });
    }
  }
}

總結

1.列表的下拉刷新是通過包裹一層RefreshIndicator,自定義onRefresh回調方法實現的
2.列表上拉加載的基本思路是監聽列表滑動狀態,當列表滑動到底部時,調用定義好的加載更多邏輯。監聽列表滑動狀態有兩種方式:ScrollControllerNotificationListener,這兩種方式的實現差不多,選擇自己用得習慣的就好了,在使用ScrollController時要記得移除監聽。

參考資料

《Flutter實戰》可滾動Widgets簡介
ListView下拉刷新與加載更多

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

推薦閱讀更多精彩內容