Flutter實(shí)戰(zhàn) 滾動(dòng)監(jiān)聽及控制

2021-03-08 11:32 更新

在前幾節(jié)中,我們介紹了 Flutter 中常用的可滾動(dòng)組件,也說過可以用ScrollController來控制可滾動(dòng)組件的滾動(dòng)位置,本節(jié)先介紹一下ScrollController,然后以ListView為例,展示一下ScrollController的具體用法。最后,再介紹一下路由切換時(shí)如何來保存滾動(dòng)位置。

#6.6.1 ScrollController

ScrollController構(gòu)造函數(shù)如下:

  1. ScrollController({
  2. double initialScrollOffset = 0.0, //初始滾動(dòng)位置
  3. this.keepScrollOffset = true,//是否保存滾動(dòng)位置
  4. ...
  5. })

我們介紹一下ScrollController常用的屬性和方法:

  • offset:可滾動(dòng)組件當(dāng)前的滾動(dòng)位置。
  • jumpTo(double offset)、animateTo(double offset,...):這兩個(gè)方法用于跳轉(zhuǎn)到指定的位置,它們不同之處在于,后者在跳轉(zhuǎn)時(shí)會(huì)執(zhí)行一個(gè)動(dòng)畫,而前者不會(huì)。

ScrollController還有一些屬性和方法,我們將在后面原理部分解釋。

#滾動(dòng)監(jiān)聽

ScrollController間接繼承自Listenable,我們可以根據(jù)ScrollController來監(jiān)聽滾動(dòng)事件,如:

  1. controller.addListener(()=>print(controller.offset))

#示例

我們創(chuàng)建一個(gè)ListView,當(dāng)滾動(dòng)位置發(fā)生變化時(shí),我們先打印出當(dāng)前滾動(dòng)位置,然后判斷當(dāng)前位置是否超過1000像素,如果超過則在屏幕右下角顯示一個(gè)“返回頂部”的按鈕,該按鈕點(diǎn)擊后可以使 ListView 恢復(fù)到初始位置;如果沒有超過1000像素,則隱藏“返回頂部”按鈕。代碼如下:

  1. class ScrollControllerTestRoute extends StatefulWidget {
  2. @override
  3. ScrollControllerTestRouteState createState() {
  4. return new ScrollControllerTestRouteState();
  5. }
  6. }
  7. class ScrollControllerTestRouteState extends State<ScrollControllerTestRoute> {
  8. ScrollController _controller = new ScrollController();
  9. bool showToTopBtn = false; //是否顯示“返回到頂部”按鈕
  10. @override
  11. void initState() {
  12. super.initState();
  13. //監(jiān)聽滾動(dòng)事件,打印滾動(dòng)位置
  14. _controller.addListener(() {
  15. print(_controller.offset); //打印滾動(dòng)位置
  16. if (_controller.offset < 1000 && showToTopBtn) {
  17. setState(() {
  18. showToTopBtn = false;
  19. });
  20. } else if (_controller.offset >= 1000 && showToTopBtn == false) {
  21. setState(() {
  22. showToTopBtn = true;
  23. });
  24. }
  25. });
  26. }
  27. @override
  28. void dispose() {
  29. //為了避免內(nèi)存泄露,需要調(diào)用_controller.dispose
  30. _controller.dispose();
  31. super.dispose();
  32. }
  33. @override
  34. Widget build(BuildContext context) {
  35. return Scaffold(
  36. appBar: AppBar(title: Text("滾動(dòng)控制")),
  37. body: Scrollbar(
  38. child: ListView.builder(
  39. itemCount: 100,
  40. itemExtent: 50.0, //列表項(xiàng)高度固定時(shí),顯式指定高度是一個(gè)好習(xí)慣(性能消耗小)
  41. controller: _controller,
  42. itemBuilder: (context, index) {
  43. return ListTile(title: Text("$index"),);
  44. }
  45. ),
  46. ),
  47. floatingActionButton: !showToTopBtn ? null : FloatingActionButton(
  48. child: Icon(Icons.arrow_upward),
  49. onPressed: () {
  50. //返回到頂部時(shí)執(zhí)行動(dòng)畫
  51. _controller.animateTo(.0,
  52. duration: Duration(milliseconds: 200),
  53. curve: Curves.ease
  54. );
  55. }
  56. ),
  57. );
  58. }
  59. }

代碼說明已經(jīng)包含在注釋里,下面我們看看運(yùn)行效果:

圖6-14圖6-15

由于列表項(xiàng)高度為50像素,當(dāng)滑動(dòng)到第20個(gè)列表項(xiàng)后,右下角“返回頂部”按鈕會(huì)顯示,點(diǎn)擊該按鈕, ListView 會(huì)在返回頂部的過程中執(zhí)行一個(gè)滾動(dòng)動(dòng)畫,動(dòng)畫時(shí)間是200毫秒,動(dòng)畫曲線是Curves.ease,關(guān)于動(dòng)畫的詳細(xì)內(nèi)容我們將在后面“動(dòng)畫”一章中詳細(xì)介紹。

#滾動(dòng)位置恢復(fù)

PageStorage是一個(gè)用于保存頁面(路由)相關(guān)數(shù)據(jù)的組件,它并不會(huì)影響子樹的UI外觀,其實(shí),PageStorage是一個(gè)功能型組件,它擁有一個(gè)存儲(chǔ)桶(bucket),子樹中的 Widget 可以通過指定不同的PageStorageKey來存儲(chǔ)各自的數(shù)據(jù)或狀態(tài)。

每次滾動(dòng)結(jié)束,可滾動(dòng)組件都會(huì)將滾動(dòng)位置offset存儲(chǔ)到PageStorage中,當(dāng)可滾動(dòng)組件重新創(chuàng)建時(shí)再恢復(fù)。如果ScrollController.keepScrollOffsetfalse,則滾動(dòng)位置將不會(huì)被存儲(chǔ),可滾動(dòng)組件重新創(chuàng)建時(shí)會(huì)使用ScrollController.initialScrollOffset;ScrollController.keepScrollOffsettrue時(shí),可滾動(dòng)組件在第一次創(chuàng)建時(shí),會(huì)滾動(dòng)到initialScrollOffset處,因?yàn)檫@時(shí)還沒有存儲(chǔ)過滾動(dòng)位置。在接下來的滾動(dòng)中就會(huì)存儲(chǔ)、恢復(fù)滾動(dòng)位置,而initialScrollOffset會(huì)被忽略。

當(dāng)一個(gè)路由中包含多個(gè)可滾動(dòng)組件時(shí),如果你發(fā)現(xiàn)在進(jìn)行一些跳轉(zhuǎn)或切換操作后,滾動(dòng)位置不能正確恢復(fù),這時(shí)你可以通過顯式指定PageStorageKey來分別跟蹤不同的可滾動(dòng)組件的位置,如:

  1. ListView(key: PageStorageKey(1), ... );
  2. ...
  3. ListView(key: PageStorageKey(2), ... );

不同的PageStorageKey,需要不同的值,這樣才可以為不同可滾動(dòng)組件保存其滾動(dòng)位置。

注意:一個(gè)路由中包含多個(gè)可滾動(dòng)組件時(shí),如果要分別跟蹤它們的滾動(dòng)位置,并非一定就得給他們分別提供PageStorageKey。這是因?yàn)?Scrollable 本身是一個(gè) StatefulWidget,它的狀態(tài)中也會(huì)保存當(dāng)前滾動(dòng)位置,所以,只要可滾動(dòng)組件本身沒有被從樹上 detach 掉,那么其 State 就不會(huì)銷毀(dispose),滾動(dòng)位置就不會(huì)丟失。只有當(dāng) Widget 發(fā)生結(jié)構(gòu)變化,導(dǎo)致可滾動(dòng)組件的 State 銷毀或重新構(gòu)建時(shí)才會(huì)丟失狀態(tài),這種情況就需要顯式指定PageStorageKey,通過PageStorage來存儲(chǔ)滾動(dòng)位置,一個(gè)典型的場景是在使用TabBarView時(shí),在 Tab 發(fā)生切換時(shí), Tab 頁中的可滾動(dòng)組件的 State 就會(huì)銷毀,這時(shí)如果想恢復(fù)滾動(dòng)位置就需要指定PageStorageKey。

#ScrollPosition

ScrollPosition 是用來保存可滾動(dòng)組件的滾動(dòng)位置的。一個(gè)ScrollController對(duì)象可以同時(shí)被多個(gè)可滾動(dòng)組件使用,ScrollController會(huì)為每一個(gè)可滾動(dòng)組件創(chuàng)建一個(gè)ScrollPosition對(duì)象,這些ScrollPosition保存在ScrollControllerpositions屬性中(List<ScrollPosition>)。ScrollPosition是真正保存滑動(dòng)位置信息的對(duì)象,offset只是一個(gè)便捷屬性:

  1. double get offset => position.pixels;

一個(gè)ScrollController雖然可以對(duì)應(yīng)多個(gè)可滾動(dòng)組件,但是有一些操作,如讀取滾動(dòng)位置offset,則需要一對(duì)一!但是我們?nèi)匀豢梢栽谝粚?duì)多的情況下,通過其它方法讀取滾動(dòng)位置,舉個(gè)例子,假設(shè)一個(gè)ScrollController同時(shí)被兩個(gè)可滾動(dòng)組件使用,那么我們可以通過如下方式分別讀取他們的滾動(dòng)位置:

  1. ...
  2. controller.positions.elementAt(0).pixels
  3. controller.positions.elementAt(1).pixels
  4. ...

我們可以通過controller.positions.length來確定controller被幾個(gè)可滾動(dòng)組件使用。

#ScrollPosition的方法

ScrollPosition有兩個(gè)常用方法:animateTo()jumpTo(),它們是真正來控制跳轉(zhuǎn)滾動(dòng)位置的方法,ScrollController的這兩個(gè)同名方法,內(nèi)部最終都會(huì)調(diào)用ScrollPosition的。

#ScrollController控制原理

我們來介紹一下ScrollController的另外三個(gè)方法:

  1. ScrollPosition createScrollPosition(
  2. ScrollPhysics physics,
  3. ScrollContext context,
  4. ScrollPosition oldPosition);
  5. void attach(ScrollPosition position) ;
  6. void detach(ScrollPosition position) ;

當(dāng)ScrollController和可滾動(dòng)組件關(guān)聯(lián)時(shí),可滾動(dòng)組件首先會(huì)調(diào)用ScrollControllercreateScrollPosition()方法來創(chuàng)建一個(gè)ScrollPosition來存儲(chǔ)滾動(dòng)位置信息,接著,可滾動(dòng)組件會(huì)調(diào)用attach()方法,將創(chuàng)建的ScrollPosition添加到ScrollControllerpositions屬性中,這一步稱為“注冊(cè)位置”,只有注冊(cè)后animateTo()jumpTo()才可以被調(diào)用。

當(dāng)可滾動(dòng)組件銷毀時(shí),會(huì)調(diào)用ScrollControllerdetach()方法,將其ScrollPosition對(duì)象從ScrollControllerpositions屬性中移除,這一步稱為“注銷位置”,注銷后animateTo()jumpTo() 將不能再被調(diào)用。

需要注意的是,ScrollControlleranimateTo()jumpTo()內(nèi)部會(huì)調(diào)用所有ScrollPositionanimateTo()jumpTo(),以實(shí)現(xiàn)所有和該ScrollController關(guān)聯(lián)的可滾動(dòng)組件都滾動(dòng)到指定的位置。

#6.6.2 滾動(dòng)監(jiān)聽

Flutter Widget 樹中子 Widget 可以通過發(fā)送通知(Notification)與父(包括祖先)Widget 通信。父級(jí)組件可以通過NotificationListener組件來監(jiān)聽自己關(guān)注的通知,這種通信方式類似于 Web 開發(fā)中瀏覽器的事件冒泡,我們?cè)?Flutter 中沿用“冒泡”這個(gè)術(shù)語,關(guān)于通知冒泡我們將在后面“事件處理與通知”一章中詳細(xì)介紹。

可滾動(dòng)組件在滾動(dòng)時(shí)會(huì)發(fā)送ScrollNotification類型的通知,ScrollBar正是通過監(jiān)聽滾動(dòng)通知來實(shí)現(xiàn)的。通過NotificationListener監(jiān)聽滾動(dòng)事件和通過ScrollController有兩個(gè)主要的不同:

  1. 通過 NotificationListener 可以在從可滾動(dòng)組件到 widget 樹根之間任意位置都能監(jiān)聽。而ScrollController只能和具體的可滾動(dòng)組件關(guān)聯(lián)后才可以。
  2. 收到滾動(dòng)事件后獲得的信息不同;NotificationListener在收到滾動(dòng)事件時(shí),通知中會(huì)攜帶當(dāng)前滾動(dòng)位置和 ViewPort 的一些信息,而ScrollController只能獲取當(dāng)前滾動(dòng)位置。

#示例

下面,我們監(jiān)聽ListView的滾動(dòng)通知,然后顯示當(dāng)前滾動(dòng)進(jìn)度百分比:

  1. import 'package:flutter/material.dart';
  2. class ScrollNotificationTestRoute extends StatefulWidget {
  3. @override
  4. _ScrollNotificationTestRouteState createState() =>
  5. new _ScrollNotificationTestRouteState();
  6. }
  7. class _ScrollNotificationTestRouteState
  8. extends State<ScrollNotificationTestRoute> {
  9. String _progress = "0%"; //保存進(jìn)度百分比
  10. @override
  11. Widget build(BuildContext context) {
  12. return Scrollbar( //進(jìn)度條
  13. // 監(jiān)聽滾動(dòng)通知
  14. child: NotificationListener<ScrollNotification>(
  15. onNotification: (ScrollNotification notification) {
  16. double progress = notification.metrics.pixels /
  17. notification.metrics.maxScrollExtent;
  18. //重新構(gòu)建
  19. setState(() {
  20. _progress = "${(progress * 100).toInt()}%";
  21. });
  22. print("BottomEdge: ${notification.metrics.extentAfter == 0}");
  23. //return true; //放開此行注釋后,進(jìn)度條將失效
  24. },
  25. child: Stack(
  26. alignment: Alignment.center,
  27. children: <Widget>[
  28. ListView.builder(
  29. itemCount: 100,
  30. itemExtent: 50.0,
  31. itemBuilder: (context, index) {
  32. return ListTile(title: Text("$index"));
  33. }
  34. ),
  35. CircleAvatar( //顯示進(jìn)度百分比
  36. radius: 30.0,
  37. child: Text(_progress),
  38. backgroundColor: Colors.black54,
  39. )
  40. ],
  41. ),
  42. ),
  43. );
  44. }
  45. }

運(yùn)行結(jié)果如圖6-16所示:

圖6-16

在接收到滾動(dòng)事件時(shí),參數(shù)類型為ScrollNotification,它包括一個(gè)metrics屬性,它的類型是ScrollMetrics,該屬性包含當(dāng)前 ViewPort 及滾動(dòng)位置等信息:

  • pixels:當(dāng)前滾動(dòng)位置。
  • maxScrollExtent:最大可滾動(dòng)長度。
  • extentBefore:滑出 ViewPort 頂部的長度;此示例中相當(dāng)于頂部滑出屏幕上方的列表長度。
  • extentInside:ViewPort 內(nèi)部長度;此示例中屏幕顯示的列表部分的長度。
  • extentAfter:列表中未滑入 ViewPort 部分的長度;此示例中列表底部未顯示到屏幕范圍部分的長度。
  • atEdge:是否滑到了可滾動(dòng)組件的邊界(此示例中相當(dāng)于列表頂或底部)。

ScrollMetrics 還有一些其它屬性,讀者可以自行查閱 API 文檔。

以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)