## 滾動監聽及控制
在前幾節中,我們介紹了Flutter中常用的可滾動Widget,也說過可以用ScrollController來控制可滾動widget的滾動位置,本節先介紹一下ScrollController,然后以ListView為例,展示一下ScrollController的具體用法。最后,再介紹一下路由切換時如何來保存滾動位置。
### ScrollController
構造函數:
```
ScrollController({
double initialScrollOffset = 0.0, //初始滾動位置
this.keepScrollOffset = true,//是否保存滾動位置
...
})
```
我們介紹一下ScrollController常用的屬性和方法:
- `offset`:可滾動Widget當前滾動的位置。
- `jumpTo(double offset)`、`animateTo(double offset,...)`:這兩個方法用于跳轉到指定的位置,它們不同之處在于,后者在跳轉時會執行一個動畫,而前者不會。
ScrollController還有一些屬性和方法,我們將在后面原理部分解釋。
#### 滾動監聽
ScrollController間接繼承自Listenable,我們可以根據ScrollController來監聽滾動事件。如:
```
controller.addListener(()=>print(controller.offset))
```
### 示例
我們創建一個ListView,當滾動位置發生變化時,我們先打印出當前滾動位置,然后判斷當前位置是否超過1000像素,如果超過則在屏幕右下角顯示一個“返回頂部”的按鈕,該按鈕點擊后可以使ListView恢復到初始位置;如果沒有超過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() {
//監聽滾動事件,打印滾動位置
_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() {
//為了避免內存泄露,需要調用_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: () {
//返回到頂部時執行動畫
_controller.animateTo(.0,
duration: Duration(milliseconds: 200),
curve: Curves.ease
);
}
),
);
}
}
```
代碼說明已經包含在注釋里,下面我們看看運行效果:

由于列表項高度為50像素,當滑動到第20個列表項后,右下角“返回頂部”按鈕會顯示,點擊該按鈕,ListView會在返回頂部的過程中執行一個滾動動畫,動畫時間是200毫秒,動畫曲線是Curves.ease,關于動畫的詳細內容我們將在后面“動畫”一章中詳細介紹。
### 滾動位置恢復
PageStorage是一個用于保存頁面(路由)相關數據的Widget,它并不會影響子樹的UI外觀,其實,PageStorage是一個功能型Widget,它擁有一個存儲桶(bucket),子樹中的Widget可以通過指定不同的PageStorageKey來存儲各自的數據或狀態。
每次滾動結束,Scrollable Widget都會將滾動位置`offset`存儲到PageStorage中,當Scrollable Widget 重新創建時再恢復。如果`ScrollController.keepScrollOffset`為`false`,則滾動位置將不會被存儲,Scrollable Widget重新創建時會使用`ScrollController.initialScrollOffset`;`ScrollController.keepScrollOffset`為`true`時,Scrollable Widget在**第一次**創建時,會滾動到`initialScrollOffset`處,因為這時還沒有存儲過滾動位置。在接下來的滾動中就會存儲、恢復滾動位置,而`initialScrollOffset`會被忽略。
當一個路由中包含多個Scrollable Widget時,如果你發現在進行一些跳轉或切換操作后,滾動位置不能正確恢復,這時你可以通過顯式指定PageStorageKey來分別跟蹤不同Scrollable Widget的位置,如:
```
ListView(key: PageStorageKey(1), ... );
...
ListView(key: PageStorageKey(2), ... );
```
不同的PageStorageKey,需要不同的值,這樣才可以區分為不同Scrollable Widget保存的滾動位置。
> 注意:一個路由中包含多個Scrollable Widget時,如果要分別跟蹤它們的滾動位置,并非一定就得給他們分別提供PageStorageKey。這是因為Scrollable本身是一個StatefulWidget,它的狀態中也會保存當前滾動位置,所以,只要Scrollable Widget本身沒有被從樹上detach掉,那么其State就不會銷毀(dispose),滾動位置就不會丟失。只有當Widget發生結構變化,導致Scrollable Widget的State銷毀或重新構建時才會丟失狀態,這種情況就需要顯式指定PageStorageKey,通過PageStorage來存儲滾動位置,一個典型的場景是在使用TabBarView時,在Tab發生切換時,Tab頁中的Scrollable Widget的State就會銷毀,這時如果想恢復滾動位置就需要指定PageStorageKey。
### ScrollPosition
一個ScrollController可以同時被多個Scrollable Widget使用,ScrollController會為每一個Scrollable Widget創建一個ScrollPosition對象,這些ScrollPosition保存在ScrollController的`positions`屬性中(List)。ScrollPosition是真正保存滑動位置信息的對象,`offset`只是一個便捷屬性:
```
double get offset => position.pixels;
```
一個ScrollController雖然可以對應多個Scrollable Widget,但是有一些操作,如讀取滾動位置`offset`,則需要一對一,但是我們仍然可以在一對多的情況下,通過其它方法讀取滾動位置,舉個例子,假設一個ScrollController同時被兩個Scrollable Widget使用,那么我們可以通過如下方式分別讀取他們的滾動位置:
```
...
controller.positions.elementAt(0).pixels
controller.positions.elementAt(1).pixels
...
```
我們可以通過`controller.positions.length`來確定`controller`被幾個Scrollable Widget使用。
#### 方法
ScrollPosition有兩個常用方法:`animateTo()` 和 `jumpTo()`,它們是真正來控制跳轉滾動位置的方法,ScrollController的這兩個同名方法,內部最終都會調用ScrollPosition的。
### ScrollController控制原理
我們來介紹一下ScrollController的另外三個方法:
```
ScrollPosition createScrollPosition(
ScrollPhysics physics,
ScrollContext context,
ScrollPosition oldPosition);
void attach(ScrollPosition position) ;
void detach(ScrollPosition position) ;
```
當ScrollController和Scrollable Widget關聯時,Scrollable Widget首先會調用ScrollController的`createScrollPosition()`方法來創建一個ScrollPosition來存儲滾動位置信息,接著,Scrollable Widget會調用`attach()`方法,將創建的ScrollPosition添加到ScrollController的`positions`屬性中,這一步稱為“注冊位置”,只有注冊后`animateTo()` 和 `jumpTo()`才可以被調用。當Scrollable Widget銷毀時,會調用ScrollController的`detach()`方法,將其ScrollPosition對象從ScrollController的`positions`屬性中移除,這一步稱為“注銷位置”,注銷后`animateTo()` 和 `jumpTo()` 將不能再被調用。
需要注意的是,ScrollController的`animateTo()` 和 `jumpTo()`內部會調用所有ScrollPosition的`animateTo()` 和 `jumpTo()`,以實現所有和該ScrollController關聯的Scrollable Widget都滾動到指定的位置。
## 滾動監聽
Flutter Widget樹中子Widget可以通過發送通知(Notification)與父(包括祖先)Widget通信。父Widget可以通過NotificationListener Widget來監聽自己關注的通知,這種通信方式類似于Web開發中瀏覽器的事件冒泡,我們在Flutter中沿用“冒泡”這個術語。Scrollable Widget在滾動時會發送ScrollNotification類型的通知,ScrollBar正是通過監聽滾動通知來實現的。通過NotificationListener監聽滾動事件和通過ScrollController有兩個主要的不同:
1. 通過NotificationListener可以在從Scrollable Widget到Widget樹根之間任意位置都能監聽。而ScrollController只能和具體的Scrollable Widget關聯后才可以。
2. 收到滾動事件后獲得的信息不同;NotificationListener在收到滾動事件時,通知中會攜帶當前滾動位置和ViewPort的一些信息,而ScrollController只能獲取當前滾動位置。
### NotificationListener
NotificationListener是一個Widget,模板參數T是想監聽的通知類型,如果省略,則所有類型通知都會被監聽,如果指定特定類型,則只有該類型的通知會被監聽。NotificationListener需要一個onNotification回調函數,用于實現監聽處理邏輯,該回調可以返回一個布爾值,代表是否阻止該事件繼續向上冒泡,如果為`true`時,則冒泡終止,事件停止向上傳播,如果不返回或者返回值為`false` 時,則冒泡繼續。
### 示例
下面,我們監聽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( //進度條
// 監聽滾動通知
child: NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
double progress = notification.metrics.pixels /
notification.metrics.maxScrollExtent;
//重新構建
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,
)
],
),
),
);
}
}
```
我們看一看運行結果:

在接收到滾動事件時,參數類型為ScrollNotification,它包括一個`metrics`屬性,它的類型是ScrollMetrics,該屬性包含當前ViewPort及滾動位置等信息:
- pixels:當前滾動位置。
- maxScrollExtent:最大可滾動長度。
- extentBefore:滑出ViewPort頂部的長度;此示例中相當于頂部滑出屏幕上方的列表長度。
- extentInside:ViewPort內部長度;此示例中屏幕顯示的列表部分的長度。
- extentAfter:列表中未滑入ViewPort部分的長度;此示例中列表底部未顯示到屏幕范圍部分的長度。
- atEdge:是否滑到了Scrollable Widget的邊界(此示例中相當于列表頂或底部)。
ScrollMetrics還有一些其它屬性,讀者可以自行查閱API文檔。
- 緣起
- 起步
- 移動開發技術簡介
- Flutter簡介
- 搭建Flutter開發環境
- 常見配置問題
- Dart語言簡介
- 第一個Flutter應用
- 計數器示例
- 路由管理
- 包管理
- 資源管理
- 調試Flutter APP
- Dart線程模型及異常捕獲
- 基礎Widgets
- Widget簡介
- 文本、字體樣式
- 按鈕
- 圖片和Icon
- 單選框和復選框
- 輸入框和表單
- 布局類Widgets
- 布局類Widgets簡介
- 線性布局Row、Column
- 彈性布局Flex
- 流式布局Wrap、Flow
- 層疊布局Stack、Positioned
- 容器類Widgets
- Padding
- 布局限制類容器ConstrainedBox、SizeBox
- 裝飾容器DecoratedBox
- 變換Transform
- Container容器
- Scaffold、TabBar、底部導航
- 可滾動Widgets
- 可滾動Widgets簡介
- SingleChildScrollView
- ListView
- GridView
- CustomScrollView
- 滾動監聽及控制ScrollController
- 功能型Widgets
- 導航返回攔截-WillPopScope
- 數據共享-InheritedWidget
- 主題-Theme
- 事件處理與通知
- 原始指針事件處理
- 手勢識別
- 全局事件總線
- 通知Notification
- 動畫
- Flutter動畫簡介
- 動畫結構
- 自定義路由過渡動畫
- Hero動畫
- 交錯動畫
- 自定義Widget
- 自定義Widget方法簡介
- 通過組合現有Widget實現
- 實例:TurnBox
- CustomPaint與Canvas
- 實例:圓形漸變進度條(自繪)
- 文件操作與網絡請求
- 文件操作
- Http請求-HttpClient
- Http請求-Dio package
- 實例:Http分塊下載
- WebSocket
- 使用Socket API
- Json轉Model
- 包與插件
- 開發package
- 插件開發:平臺通道簡介
- 插件開發:實現Android端API
- 插件開發:實現IOS端API
- 系統能力調用
- 國際化
- 讓App支持多語言
- 實現Localizations
- 使用Intl包
- Flutter核心原理
- Flutter UI系統
- Element和BuildContext
- RenderObject與RenderBox
- Flutter從啟動到顯示