在前幾節(jié)中,我們介紹了 Flutter 中常用的可滾動組件,也說過可以用ScrollController
來控制可滾動組件的滾動位置,本節(jié)先介紹一下ScrollController
,然后以ListView
為例,展示一下ScrollController
的具體用法。最后,再介紹一下路由切換時如何來保存滾動位置。
ScrollController
構(gòu)造函數(shù)如下:
ScrollController({
double initialScrollOffset = 0.0, //初始滾動位置
this.keepScrollOffset = true,//是否保存滾動位置
...
})
我們介紹一下ScrollController
常用的屬性和方法:
offset
:可滾動組件當前的滾動位置。jumpTo(double offset)
、animateTo(double offset,...)
:這兩個方法用于跳轉(zhuǎn)到指定的位置,它們不同之處在于,后者在跳轉(zhuǎn)時會執(zhí)行一個動畫,而前者不會。
ScrollController
還有一些屬性和方法,我們將在后面原理部分解釋。
ScrollController
間接繼承自Listenable
,我們可以根據(jù)ScrollController
來監(jiān)聽滾動事件,如:
controller.addListener(()=>print(controller.offset))
我們創(chuàng)建一個ListView
,當滾動位置發(fā)生變化時,我們先打印出當前滾動位置,然后判斷當前位置是否超過1000像素,如果超過則在屏幕右下角顯示一個“返回頂部”的按鈕,該按鈕點擊后可以使 ListView 恢復(fù)到初始位置;如果沒有超過1000像素,則隱藏“返回頂部”按鈕。代碼如下:
class ScrollControllerTestRoute extends StatefulWidget {
@override
ScrollControllerTestRouteState createState() {
return new ScrollControllerTestRouteState();
}
}
class ScrollControllerTestRouteState extends State<ScrollControllerTestRoute> {
ScrollController _controller = new ScrollController();
bool showToTopBtn = false; //是否顯示“返回到頂部”按鈕
@override
void initState() {
super.initState();
//監(jiān)聽滾動事件,打印滾動位置
_controller.addListener(() {
print(_controller.offset); //打印滾動位置
if (_controller.offset < 1000 && showToTopBtn) {
setState(() {
showToTopBtn = false;
});
} else if (_controller.offset >= 1000 && showToTopBtn == false) {
setState(() {
showToTopBtn = true;
});
}
});
}
@override
void dispose() {
//為了避免內(nèi)存泄露,需要調(diào)用_controller.dispose
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("滾動控制")),
body: Scrollbar(
child: ListView.builder(
itemCount: 100,
itemExtent: 50.0, //列表項高度固定時,顯式指定高度是一個好習慣(性能消耗小)
controller: _controller,
itemBuilder: (context, index) {
return ListTile(title: Text("$index"),);
}
),
),
floatingActionButton: !showToTopBtn ? null : FloatingActionButton(
child: Icon(Icons.arrow_upward),
onPressed: () {
//返回到頂部時執(zhí)行動畫
_controller.animateTo(.0,
duration: Duration(milliseconds: 200),
curve: Curves.ease
);
}
),
);
}
}
代碼說明已經(jīng)包含在注釋里,下面我們看看運行效果:
由于列表項高度為50像素,當滑動到第20個列表項后,右下角“返回頂部”按鈕會顯示,點擊該按鈕, ListView 會在返回頂部的過程中執(zhí)行一個滾動動畫,動畫時間是200毫秒,動畫曲線是Curves.ease
,關(guān)于動畫的詳細內(nèi)容我們將在后面“動畫”一章中詳細介紹。
PageStorage
是一個用于保存頁面(路由)相關(guān)數(shù)據(jù)的組件,它并不會影響子樹的UI外觀,其實,PageStorage
是一個功能型組件,它擁有一個存儲桶(bucket),子樹中的 Widget 可以通過指定不同的PageStorageKey
來存儲各自的數(shù)據(jù)或狀態(tài)。
每次滾動結(jié)束,可滾動組件都會將滾動位置offset
存儲到PageStorage
中,當可滾動組件重新創(chuàng)建時再恢復(fù)。如果ScrollController.keepScrollOffset
為false
,則滾動位置將不會被存儲,可滾動組件重新創(chuàng)建時會使用ScrollController.initialScrollOffset
;ScrollController.keepScrollOffset
為true
時,可滾動組件在第一次創(chuàng)建時,會滾動到initialScrollOffset
處,因為這時還沒有存儲過滾動位置。在接下來的滾動中就會存儲、恢復(fù)滾動位置,而initialScrollOffset
會被忽略。
當一個路由中包含多個可滾動組件時,如果你發(fā)現(xiàn)在進行一些跳轉(zhuǎn)或切換操作后,滾動位置不能正確恢復(fù),這時你可以通過顯式指定PageStorageKey
來分別跟蹤不同的可滾動組件的位置,如:
ListView(key: PageStorageKey(1), ... );
...
ListView(key: PageStorageKey(2), ... );
不同的PageStorageKey
,需要不同的值,這樣才可以為不同可滾動組件保存其滾動位置。
注意:一個路由中包含多個可滾動組件時,如果要分別跟蹤它們的滾動位置,并非一定就得給他們分別提供
PageStorageKey
。這是因為 Scrollable 本身是一個 StatefulWidget,它的狀態(tài)中也會保存當前滾動位置,所以,只要可滾動組件本身沒有被從樹上 detach 掉,那么其 State 就不會銷毀(dispose),滾動位置就不會丟失。只有當 Widget 發(fā)生結(jié)構(gòu)變化,導(dǎo)致可滾動組件的 State 銷毀或重新構(gòu)建時才會丟失狀態(tài),這種情況就需要顯式指定PageStorageKey
,通過PageStorage
來存儲滾動位置,一個典型的場景是在使用TabBarView
時,在 Tab 發(fā)生切換時, Tab 頁中的可滾動組件的 State 就會銷毀,這時如果想恢復(fù)滾動位置就需要指定PageStorageKey
。
ScrollPosition 是用來保存可滾動組件的滾動位置的。一個ScrollController
對象可以同時被多個可滾動組件使用,ScrollController
會為每一個可滾動組件創(chuàng)建一個ScrollPosition
對象,這些ScrollPosition
保存在ScrollController
的positions
屬性中(List<ScrollPosition>
)。ScrollPosition
是真正保存滑動位置信息的對象,offset
只是一個便捷屬性:
double get offset => position.pixels;
一個ScrollController
雖然可以對應(yīng)多個可滾動組件,但是有一些操作,如讀取滾動位置offset
,則需要一對一!但是我們?nèi)匀豢梢栽谝粚Χ嗟那闆r下,通過其它方法讀取滾動位置,舉個例子,假設(shè)一個ScrollController
同時被兩個可滾動組件使用,那么我們可以通過如下方式分別讀取他們的滾動位置:
...
controller.positions.elementAt(0).pixels
controller.positions.elementAt(1).pixels
...
我們可以通過controller.positions.length
來確定controller
被幾個可滾動組件使用。
ScrollPosition
有兩個常用方法:animateTo()
和 jumpTo()
,它們是真正來控制跳轉(zhuǎn)滾動位置的方法,ScrollController
的這兩個同名方法,內(nèi)部最終都會調(diào)用ScrollPosition
的。
我們來介紹一下ScrollController
的另外三個方法:
ScrollPosition createScrollPosition(
ScrollPhysics physics,
ScrollContext context,
ScrollPosition oldPosition);
void attach(ScrollPosition position) ;
void detach(ScrollPosition position) ;
當ScrollController
和可滾動組件關(guān)聯(lián)時,可滾動組件首先會調(diào)用ScrollController
的createScrollPosition()
方法來創(chuàng)建一個ScrollPosition
來存儲滾動位置信息,接著,可滾動組件會調(diào)用attach()
方法,將創(chuàng)建的ScrollPosition
添加到ScrollController
的positions
屬性中,這一步稱為“注冊位置”,只有注冊后animateTo()
和 jumpTo()
才可以被調(diào)用。
當可滾動組件銷毀時,會調(diào)用ScrollController
的detach()
方法,將其ScrollPosition
對象從ScrollController
的positions
屬性中移除,這一步稱為“注銷位置”,注銷后animateTo()
和 jumpTo()
將不能再被調(diào)用。
需要注意的是,ScrollController
的animateTo()
和 jumpTo()
內(nèi)部會調(diào)用所有ScrollPosition
的animateTo()
和 jumpTo()
,以實現(xiàn)所有和該ScrollController
關(guān)聯(lián)的可滾動組件都滾動到指定的位置。
Flutter Widget 樹中子 Widget 可以通過發(fā)送通知(Notification)與父(包括祖先)Widget 通信。父級組件可以通過NotificationListener
組件來監(jiān)聽自己關(guān)注的通知,這種通信方式類似于 Web 開發(fā)中瀏覽器的事件冒泡,我們在 Flutter 中沿用“冒泡”這個術(shù)語,關(guān)于通知冒泡我們將在后面“事件處理與通知”一章中詳細介紹。
可滾動組件在滾動時會發(fā)送ScrollNotification
類型的通知,ScrollBar
正是通過監(jiān)聽滾動通知來實現(xiàn)的。通過NotificationListener
監(jiān)聽滾動事件和通過ScrollController
有兩個主要的不同:
ScrollController
只能和具體的可滾動組件關(guān)聯(lián)后才可以。NotificationListener
在收到滾動事件時,通知中會攜帶當前滾動位置和 ViewPort 的一些信息,而ScrollController
只能獲取當前滾動位置。
下面,我們監(jiān)聽ListView
的滾動通知,然后顯示當前滾動進度百分比:
import 'package:flutter/material.dart';
class ScrollNotificationTestRoute extends StatefulWidget {
@override
_ScrollNotificationTestRouteState createState() =>
new _ScrollNotificationTestRouteState();
}
class _ScrollNotificationTestRouteState
extends State<ScrollNotificationTestRoute> {
String _progress = "0%"; //保存進度百分比
@override
Widget build(BuildContext context) {
return Scrollbar( //進度條
// 監(jiān)聽滾動通知
child: NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
double progress = notification.metrics.pixels /
notification.metrics.maxScrollExtent;
//重新構(gòu)建
setState(() {
_progress = "${(progress * 100).toInt()}%";
});
print("BottomEdge: ${notification.metrics.extentAfter == 0}");
//return true; //放開此行注釋后,進度條將失效
},
child: Stack(
alignment: Alignment.center,
children: <Widget>[
ListView.builder(
itemCount: 100,
itemExtent: 50.0,
itemBuilder: (context, index) {
return ListTile(title: Text("$index"));
}
),
CircleAvatar( //顯示進度百分比
radius: 30.0,
child: Text(_progress),
backgroundColor: Colors.black54,
)
],
),
),
);
}
}
運行結(jié)果如圖6-16所示:
在接收到滾動事件時,參數(shù)類型為ScrollNotification
,它包括一個metrics
屬性,它的類型是ScrollMetrics
,該屬性包含當前 ViewPort 及滾動位置等信息:
pixels
:當前滾動位置。maxScrollExtent
:最大可滾動長度。extentBefore
:滑出 ViewPort 頂部的長度;此示例中相當于頂部滑出屏幕上方的列表長度。extentInside
:ViewPort 內(nèi)部長度;此示例中屏幕顯示的列表部分的長度。extentAfter
:列表中未滑入 ViewPort 部分的長度;此示例中列表底部未顯示到屏幕范圍部分的長度。atEdge
:是否滑到了可滾動組件的邊界(此示例中相當于列表頂或底部)。ScrollMetrics 還有一些其它屬性,讀者可以自行查閱 API 文檔。
更多建議: