Flutter滑動列表實現


 

本篇主要幫助剖析理解 Flutter 里的列表和滑動的組成,用比較通俗易懂的方式,從常見的 ListViewNestedScrollView 的內部實現,幫助你更好理解和運用 Flutter 里的滑動列表。

「本篇不是教你如何使用 API ,而是一些日常開發中不常接觸,但是很重要的內容」

Flutter 滑動列表

在 Flutter 里我們常見的滑動列表場景,簡單地說其實是由三部分組成:

  • Viewport : 它是一個 MultiChildRenderObjectWidget 的控件 ,「它提供的是一個“視窗”的作用,也就是列表所在的可視區域大小;」
  • Scrollable「它主要通過對手勢的處理來實現滑動效果」 ,比如VerticalDragGestureRecognizerHorizontalDragGestureRecognizer;
  • Sliver : 准確來說應該是 RenderSliver「它主要是用於在 Viewport 里面布局和渲染內容;」

ListView 為例,如上圖所示是 ListView 滑動過程的變化,其中:

  • 綠色的 Viewport 就是我們看到的列表窗口大小;
  • 紫色部分就是處理手勢的 Scrollable,讓黃色部分 SliverListViewport 里產生滑動;
  • 黃色的部分就是 SliverList , 當我們滑動時其實就是它在 Viewport 里的位置發生了變化;

了解完這個基礎理念后,就可以知道一般情況下 ViewportScrollable 的實現都是很通用的,所以一般在 「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 都進行布局和計算,繪制時主要也是通過 offsetclip 等來完成移動效果,這樣的實現當 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 , 因為 ListViewViewport 是 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 還有:SliverPaddingSliverFillRemainingSliverFillViewportSliverPersistentHeaderSliverAppbar 等等。

NestedScrollView

為什么會把 NestedScrollView 單獨拿出來說呢?這是因為 NestedScrollView 和前面介紹的滑動列表實現不大一樣。

內部組成

如上圖所示,NestedScrollView 內部主要是通過繼承 CustomScrollView ,然后自定義一個 NestedScrollViewViewport 來實現聯動的效果。

那這有什么特別的呢?如下代碼所示,這是使用 NestedScrollView 常用的模式,那有看出什么特別的地方了嗎?

代碼里 NestedScrollViewbody 嵌套的是 ListView , 前面我們介紹了 ListView 本身就是 Viewport + Scrollable + SliverList 組合,而 NestedScrollView 本身也有 NestedScrollViewViewport

「所以 NestedScrollView 的實現本質上其實就是 Viewport 嵌套 Viewport,會有兩個 Scrollable 的存在」 ,並且嵌套的 ListView 是被放在了 NestedScrollViewSliver里面,大致如下圖所示。

這里面有幾個關鍵的對象,其中:

  • SliverFillRemaining :用於充滿 Viewport 的剩余空間,在 NestedScrollView 里面就是充滿 header 之外的剩余空間;
  • NestedScrollViewViewport : 在原 Viewport 的基礎上增加了一個 SliverOverlapAbsorberHandle 參數,SliverOverlapAbsorberHandle 本身是一個 ChangeNotifier, 主要是用來當 markNeedsLayout 時對外發出通知,比如對 header 部分;

所以 NestedScrollView 本質上兩個 Viewport 之間的嵌套,那他們之間是滑動關系是如何處理的?「這就要說到 NestedScrollView 里的 _NestedScrollCoordinator 對象。」

_NestedScrollCoordinator

_NestedScrollCoordinator 的實現比較復雜,簡單地說 _NestedScrollCoordinator 內部創建了兩個 _NestedScrollController

  • _outerController :屬於 _NestedScrollViewCustomScrollViewcontroller ,也就是它自己 controller
  • _innerController :屬於 bodycontroller
❝在 ListView 的父類 ScrollView 內部,默認情況下使用的就是 PrimaryScrollController.of(context) 這個 controller ,因為 PrimaryScrollController 是一個 InheritedWidget

而整個聯動滑動的流程,主要就是 _NestedScrollCoordinator 里和它創建的兩個 _NestedScrollController 有關系:

  • _NestedScrollController 的主要作用就是使用 _NestedScrollPosition 來替換 ScrollPosition
  • _NestedScrollCoordinator 將 _outer 和 _inner 兩個 _NestedScrollController 組合起來(_outer 和 _inner 分別被應用到 NestedScrollViewbody);
  • _NestedScrollPosition 內部將 Drag 等手勢操作傳遞回 _NestedScrollCoordinator里。
  • 最后在 _NestedScrollCoordinatordragapplyUserOffset 等方法里進行內外滾動的分配;

SliverPersistentHeader

了解完 NestedScrollView 的布局和聯動實現之外,最后簡單介紹一下 SliverPersistentHeader , 因為經常在 NestedScrollView 里使用的 SliverAppBar,本質上 SliverAppBar 的實現靠的就是 SliverPersistentHeader

SliverPersistentHeader 主要是具備 floatingpinned 兩個屬性,它們的區別主要在於使用了不同的 RenderSliver 實現,而「最終不同的地方其實就是輸出 SliverGeometry 的不同」

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

「所以 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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM