本篇主要幫助剖析理解 Flutter 里的列表和滑動的組成,用比較通俗易懂的方式,從常見的 ListView
到 NestedScrollView
的內部實現,幫助你更好理解和運用 Flutter 里的滑動列表。
❝ 「本篇不是教你如何使用 API ,而是一些日常開發中不常接觸,但是很重要的內容」。
❞
Flutter 滑動列表
在 Flutter 里我們常見的滑動列表場景,簡單地說其實是由三部分組成:
Viewport
: 它是一個 MultiChildRenderObjectWidget 的控件 ,「它提供的是一個“視窗”的作用,也就是列表所在的可視區域大小;」Scrollable
:「它主要通過對手勢的處理來實現滑動效果」 ,比如VerticalDragGestureRecognizer 和 HorizontalDragGestureRecognizer;Sliver
: 准確來說應該是 RenderSliver, 「它主要是用於在 Viewport 里面布局和渲染內容;」

以 ListView
為例,如上圖所示是 ListView
滑動過程的變化,其中:
- 綠色的
Viewport
就是我們看到的列表窗口大小; - 紫色部分就是處理手勢的
Scrollable
,讓黃色部分SliverList
在Viewport
里產生滑動; - 黃色的部分就是
SliverList
, 當我們滑動時其實就是它在Viewport
里的位置發生了變化;
了解完這個基礎理念后,就可以知道一般情況下 Viewport
和 Scrollable
的實現都是很通用的,所以一般在 「Flutter 里要實現不同的滑動列表,就是通過自定義和組合不同的 Sliver
來完成布局」。
❝ 「准確說是完成RenderSliver
的performLayout
過程,通過SliverConstraints
來得到對應的SliverGeometry
」。
❞
所以在 Flutter 里:
ListView
使用的是SliverFixedExtentList
或者SliverList
;GridView
使用的是SliverGrid
;PageView
使用的是SliverFillViewport
;
❝當然這里有一個特殊的是SingleChildScrollView
, 因為它是單個child
的可滑動控件,它並沒有使用RenderSliver
,而是直接自定義了一個RenderObject
(RenderBox) ,並且 「在performLayout
時直接調整child
的offset
來達到滑動效果」。
❞
RenderSliver
我們都知道 Flutter 中的整體渲染流程是 Widget -> Element -> RenderObejct -> Layer 這樣的過程,而 「Flutter 里的布局和繪制邏輯都在 RenderObejct
」。
而事實上 RenderObejct
也可以分為兩大基礎子類:
RenderBox
: 我們「常用的布局控件都是基於 RenderBox」 來實現布局;RenderSliver
:「主要用在 Viewport 里實現布局」, Viewport 里的直屬 children 也需要是 RenderSliver;
那到這里你可能會有一個疑問:既然前面 SingleChildScrollView
里沒有使用 RenderSliver
,直接使用 RenderBox
也可以實現滑動,「為什么還要用 Viewport + RenderSliver 的方式來實現列表滑動?」
RenderBox
在 SingleChildScrollView
內部使用的是 RenderBox
,那么在布局過程中自然而然會把整個 child
都進行布局和計算,繪制時主要也是通過 offset
和 clip
等來完成移動效果,這樣的實現當 「child
比較復雜或者過長時,性能就會變差」。
RenderSliver
RenderSliver
的實現相對 RenderBox
就復雜更多,前面介紹過 「RenderSliver
就是通過 SliverConstraints
來得到一個 SliverGeometry
」,其中:
SliverConstraints
中有 remainingPaintExtent 可以用來表示剩余的可繪制具體的大小;SliverGeometry
里也有scrollExtent
(可滑動的距離)、paintExtent
(可繪制大小)、layoutExtent
(布局大小范圍)、visible
(是否需要繪制)等參數;
所以通過這部分參數,「在 Viewport
里可以實現動態管理,節省資源,根據 SliverGeometry
判斷需要繪制多大區域的內容,還剩多少內容可以繪制,需要加載的布局時哪些等等。」
「簡單地說就是可以實現“懶加載”,按需繪制,從而得到更流暢的滑動體驗。」

以 ListView
為例,如上圖所示是一個高為 701 的 ListView
,實際布局渲染之后,對於 SliverList
輸出的 SliverGeometry
而言:
- 設定里每個 item 的高度為 114;
scrollExtent
是 2353,也就是整體可滑動距離等於 2353;paintExtent
是 701 , 因為ListView
的Viewport
是 701 ,所以從SliverConstraints
得到的remainingPaintExtent
是 701,「所以默認只需要繪制和布局高度為 701 的部分;」 (因為默認 paintExtent = layoutExtent )- 對 item 多出的藍色 8-9 部分,這是因為在
SliverConstraints
內會有一個叫remainingCacheExtent
的參數,它表示了需要提前緩存的布局區域, 也就是“預布局”的區域,這個區域默認大小是 「defaultCacheExtent= 250.0;」
❝ListView
高度為 701,defaultCacheExtent
為默認的 250,也就是得到 「第一次需要布局到底部的距離其實為 951」,按照每個 item 高度是 114 ,那么其實是有 8.3 個 item 高度,取整數也就是 9 個 item ,最終得到整體需要處理的區域大小為 114 * 9 = 1026 ,在 「SliverList
內部就是endScrollOffset
參數」。
❞
所以根據以上情況,「ListView
會輸出一個 paintExtent
為 701 ,cacheExtent
為 1026 的 SliverGeometry
」。
從這個例子可以看出,「RenderSliver
在實現可滑動列表的開銷和邏輯上,會比直接使用 RenderBox
好和靈活很多」,同時也是為什么 Viewport
里需要使用 RenderSliver
而不是 RenderBox
的原因。
❝⚠️注意,這里比較容易有一個誤區,那就是ListView
是由Viewport
+Scrollable
和一個RenderSliver
組成,所以在 「ListView
里只會有一個RenderSliver
而不是多個」,想使用多個RenderSliver
需要使用CustomScrollView
。
❞
最后順便聊下 CustomScrollView
,事實上就是一個「開放了可自定義配置 RenderSliver
數組的滑動控件」,例如:
- 通過利用
SliverList
+SliverGrid
就可以搭配出多樣化的滑動列表; - 通過
CupertinoSliverRefreshControl
+SliverList
實現類似 iOS 原生的下拉刷新列表;
其他可用的內置 Sliver
還有:SliverPadding
、SliverFillRemaining
、SliverFillViewport
、SliverPersistentHeader
、SliverAppbar
等等。
NestedScrollView
為什么會把 NestedScrollView
單獨拿出來說呢?這是因為 NestedScrollView
和前面介紹的滑動列表實現不大一樣。
內部組成

如上圖所示,NestedScrollView
內部主要是通過繼承 CustomScrollView
,然后自定義一個 NestedScrollViewViewport
來實現聯動的效果。
那這有什么特別的呢?如下代碼所示,這是使用 NestedScrollView
常用的模式,那有看出什么特別的地方了嗎?

代碼里 NestedScrollView
的 body
嵌套的是 ListView
, 前面我們介紹了 ListView
本身就是 Viewport
+ Scrollable
+ SliverList
組合,而 NestedScrollView
本身也有 NestedScrollViewViewport
。
「所以 NestedScrollView
的實現本質上其實就是 Viewport
嵌套 Viewport
,會有兩個 Scrollable
的存在」 ,並且嵌套的 ListView
是被放在了 NestedScrollView
的 Sliver
里面,大致如下圖所示。

這里面有幾個關鍵的對象,其中:
SliverFillRemaining
:用於充滿Viewport
的剩余空間,在NestedScrollView
里面就是充滿header
之外的剩余空間;NestedScrollViewViewport
: 在原Viewport
的基礎上增加了一個SliverOverlapAbsorberHandle
參數,SliverOverlapAbsorberHandle
本身是一個ChangeNotifier
, 主要是用來當markNeedsLayout
時對外發出通知,比如對 header 部分;
所以 NestedScrollView
本質上兩個 Viewport
之間的嵌套,那他們之間是滑動關系是如何處理的?「這就要說到 NestedScrollView
里的 _NestedScrollCoordinator
對象。」
_NestedScrollCoordinator
_NestedScrollCoordinator
的實現比較復雜,簡單地說 _NestedScrollCoordinator
內部創建了兩個 _NestedScrollController
:
_outerController
:屬於_NestedScrollViewCustomScrollView
的 controller ,也就是它自己 controller;_innerController
:屬於body
的 controller;

❝在ListView
的父類ScrollView
內部,默認情況下使用的就是PrimaryScrollController.of(context)
這個 controller ,因為PrimaryScrollController
是一個InheritedWidget
。
❞
而整個聯動滑動的流程,主要就是 _NestedScrollCoordinator
里和它創建的兩個 _NestedScrollController
有關系:
_NestedScrollController
的主要作用就是使用_NestedScrollPosition
來替換ScrollPosition
;_NestedScrollCoordinator
將 _outer 和 _inner 兩個_NestedScrollController
組合起來(_outer 和 _inner 分別被應用到NestedScrollView
和body
);_NestedScrollPosition
內部將Drag
等手勢操作傳遞回_NestedScrollCoordinator
里。- 最后在
_NestedScrollCoordinator
的drag
和applyUserOffset
等方法里進行內外滾動的分配;

SliverPersistentHeader
了解完 NestedScrollView
的布局和聯動實現之外,最后簡單介紹一下 SliverPersistentHeader
, 因為經常在 NestedScrollView
里使用的 SliverAppBar
,本質上 「SliverAppBar
的實現靠的就是 SliverPersistentHeader
」。
SliverPersistentHeader
主要是具備 floating
和 pinned
兩個屬性,它們的區別主要在於使用了不同的 RenderSliver
實現,而「最終不同的地方其實就是輸出 SliverGeometry
的不同」。

以第一個 _SliverFloatingPinnedPersistentHeader
和最后一個 _SliverScrollingPersistentHeader
之間的對比為例子,如下代碼所示,在需要 floating
和 pinned
的 Sliver
上,可以看到 paintExtent
和 layoutExtent
都有一個最小值。

「所以 Sliver
被固定住的原理,其實就是 Viewport
得到了它的 paintExtent
和 layoutExtent
並不為 0,所以會繼續為這個 Sliver
繪制對應區域的內容。」
最后需要注意的是,「當你使用 SliverPersistentHeader
去固定住頭部的時候,作為 body
的列表是不知道頂部有個固定區域。」 所以如果這時候不額外做一些處理,那么對於 body
而言,它的 paintOrigin
還是從最頂部開始而不是固定區域的下方。

❝如上動圖所示,可以看到 item0 並沒有在橙色區域停止滑動,而是繼續往上滑動,這就是因為作為body
的列表不知道頂部有固定區域。
❞
這時候就可以通過使用 SliverOverlapAbsorber
+ SliverOverlapInjector
的組合來解決這個問題:
- 在
SliverPersistentHeader
的外層嵌套一個SliverOverlapAbsorber
用於吸收SliverPersistentHeader
的高度; - 使用
SliverOverlapInjector
將這個高度配置到body
列表中,讓列表知道頂部存在一個固定高度的區域;

原文:https://zhuanlan.zhihu.com/p/368653631