本篇主要幫助剖析理解 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
