列表組件在移動端上尤其重要,Sliver 做爲 Flutter 列表組件中重要的一部分,開發者很是有必要了解 Sliver 的原理和用法。android
Flutter 的佈局能夠分爲兩種:數組
Sliver 是 Flutter 中的一個概念,表示可滾動佈局中的一部分,它的 child 能夠是普通的 Box Widget。緩存
ViewPort 有一些重要屬性:bash
class Viewport extends MultiChildRenderObjectWidget { /// 主軸方向 final AxisDirection axisDirection; /// 縱軸方向 final AxisDirection crossAxisDirection; /// center 決定 viewport 的 zero 基準線,也就是 viewport 從哪一個地方開始繪製,默認是第一個 sliver /// center 必須是 viewport slivers 中的一員的 key final Key center; /// 錨點,取值[0,1],和 zero 的相對位置,好比 0.5 表明 zero 被放到了 Viewport.height / 2 處 final double anchor; /// 滾動的累計值,確切的說是 viewport 從什麼地方開始顯示 final ViewportOffset offset; /// 緩存區域,也就是相對有頭尾須要預加載的高度 final double cacheExtent; /// children widget List<Widget> slivers; } 複製代碼
一圖勝千言:markdown
上圖中假設每一個 sliver 的 height 都相等且等於屏幕高度的 ⅕,這樣設置 center = sliver1,屏幕的第一個顯示的應該是 sliver 1,可是由於 anchor = 0.2,0.2 * viewport.height 正好等於 sliver1 的高度,因此屏幕上顯示的第一個是 sliver 2。app
ScrollPosition 決定了 Viewport 哪些區域是可見的,它包含了Viewport 的滾動信息,它的主要成員變量以下:dom
abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { // 滾動偏移量 double _pixels; // 設置滾動響應效果,好比滑動中止後的動畫 final ScrollPhysics physics; // 保存當前的滾動偏移量到 PageStore 中,當 Scrollable 重建後能夠恢復到當前偏移量 final bool keepScrollOffset; // 最小滾動值 double _minScrollExtent; // 最大滾動值 double _maxScrollExtent; ... } 複製代碼
ScrollPosition 的類繼承關係以下:ide
|-- Listenable
|---- ChangeNotifier
|------ ScrollPosition
|-------- ScrollPositionWithSingleContext
複製代碼
因此 ScrollPosition 能夠做爲被觀察者,當數據改變的時候能夠通知觀察者。佈局
Scrollable 是一個可滾動的 Widget,它主要負責:性能
Scrollable 自己不具備繪製內容的能力,它經過構造注入的 viewportBuilder 來建立一個 Viewport 來顯示內容,當滾動狀態變化的時候,Scrollable 就會不斷的更新 Viewport 的 offset ,Viewport 就會不斷的更新顯示內容。
Scrollable 主要結構以下:
Widget result = _ScrollableScope(
scrollable: this,
position: position,
child: Listener(
onPointerSignal: _receivedPointerSignal,
child: RawGestureDetector(
gestures: _gestureRecognizers,
...,
child: Semantics(
...
child: IgnorePointer(
...
child: widget.viewportBuilder(context, position),
),
),
),
),
);
複製代碼
和 Box 佈局使用 BoxConstraints 做爲約束相似,Sliver 佈局採用 SliverConstraints 做爲約束,但相對於 Box 要複雜的多,能夠理解爲 SliverConstraints 描述了 Viewport 和它內部的 Slivers 之間的佈局信息:
class SliverConstraints extends Constraints { // 主軸方向 final AxisDirection axisDirection; // 窗口增加方向 final GrowthDirection growthDirection; // 若是 Direction 是 AxisDirection.down,scrollOffset 表明 sliver 的 top 滑過 viewport 的 top 的值,沒滑過 viewport 的 top 時 scrollOffset 爲 0。 final double scrollOffset; // 上一個 sliver 覆蓋下一個 sliver 的大小(只有上一個 sliver 是 pinned/floating 纔有效) final double overlap; // 輪到當前 sliver 開始繪製了,須要 viewport 告訴 sliver 當前還剩下多少區域能夠繪製,受 viewport 的 size 影響 final double remainingPaintExtent; // viewport 主軸上的大小 final double viewportMainAxisExtent; // 緩存區起點(相對於 scrolloffset),若是 cacheExtent 設置爲 0,那麼 cacheOrigin 一直爲 0 final double cacheOrigin; // 剩餘的緩存區大小 final double remainingCacheExtent; ... } 複製代碼上圖中的 sliver1 會被 SliverAppBar(pinned = true)遮住,遮住的大小就是 overlap,此時 overlap 會一直大於 0,若是設置像 iOS bouncing 那樣的滑動效果,那麼當 list 滾動到頂部繼續滑動的時候 overlap 會小於 0(此刻並無東西遮蓋 sliver1,而是 sliver1 的 top 和 viewport 的 top 有間距)。
Viewport 經過 SliverConstraints 告知它內部的 sliver 本身的約束信息,好比還有多少空間可用、offset 等,那麼Sliver 則經過 SliverGeometry 反饋給 Viewport 須要佔用多少空間量。
class SliverGeometry extends Diagnosticable { // sliver 能夠滾動的範圍,能夠認爲是 sliver 的高度(若是是 AxisDierction.Down) final double scrollExtent; // 繪製起點(默認是 0.0),是相對於 sliver 開始 layout 的起點而言的,不會影響下一個 sliver 的 layoutExtent,會影響下一個 sliver 的paintExtent final double paintOrigin; // 繪製範圍 final double paintExtent; // 佈局範圍,當前 sliver 的 top 到下一個 sliver 的 top 的距離,範圍是[0,paintExtent],默認是 paintExtent,會影響下一個 sliver 的 layout 位置 final double layoutExtent; // 最大繪製大小,必須 >= paintExtent final double maxPaintExtent; // 若是 sliver 被 pinned 在邊界的時候,這個大小爲 Sliver 的自身的高度,其餘狀況爲0,好比 pinned app bar final double maxScrollObstructionExtent; // 點擊有效區域的大小,默認爲paintExtent final double hitTestExtent; // 是否可見,visible = (paintExtent > 0) final bool visible; // 是否須要作clip,省得chidren溢出 final bool hasVisualOverflow; // 當前 sliver 佔用了 SliverConstraints.remainingCacheExtent 多少像素值 final double cacheExtent; ... } 複製代碼
RenderViewport 在 layout 它內部的 slivers 的過程以下:
這個 layout 過程是一個自上而下的線性過程:
以 ScrollView 爲例,咱們串聯上面介紹的幾個 Widget 之間的關係。 先來看 ScrollView 的 build 方法:
@override Widget build(BuildContext context) { final List<Widget> slivers = buildSlivers(context); final AxisDirection axisDirection = getDirection(context); final ScrollController scrollController = primary ? PrimaryScrollController.of(context) : controller; final Scrollable scrollable = Scrollable( ... controller: scrollController, viewportBuilder: (BuildContext context, ViewportOffset offset) { return buildViewport(context, offset, axisDirection, slivers); }, ); return primary && scrollController != null ? PrimaryScrollController.none(child: scrollable) : scrollable; } 複製代碼
能夠看到 ScrollView 建立了一個 Scrollable,並傳入了構造 ViewPort 的 buildViewPort 方法。 上面講過 Scrollable 負責手勢監聽,經過 buildViewPort 建立視圖,在手勢變化的時候不停的更新 ViewPort,大概流程以下:
CustomPinnedHeader 光看一些概念會難以理解,最好的方式是 debug 一下,咱們能夠 copy 一下 SliverToBoxAdapter 的代碼自定義一個 Sliver 調試一下各個參數加深理解。
class CustomSliverWidget extends SingleChildRenderObjectWidget { const CustomSliverWidget({Key key, Widget child}) : super(key: key, child: child); @override RenderObject createRenderObject(BuildContext context) { return CustomSliver(); } } /// 一個 StickPinWidget /// 主要講述 Sliveronstraints 和 SliverGeometry 參數的做用 class CustomSliver extends RenderSliverSingleBoxAdapter { @override void performLayout() { ... // 將 SliverConstraints 轉化爲 BoxConstraints 對 child 進行 layout child.layout(constraints.asBoxConstraints(), parentUsesSize: true); ... // 計算繪製大小 final double paintedChildSize = calculatePaintOffset(constraints, from: 0.0, to: childExtent); // 計算緩存大小 final double cacheExtent = calculateCacheOffset(constraints, from: 0.0, to: childExtent); ... // 輸出 SliverGeometry geometry = SliverGeometry( scrollExtent: childExtent, paintExtent: paintedChildSize, cacheExtent: cacheExtent, maxPaintExtent: childExtent, paintOrigin: 0, hitTestExtent: paintedChildSize, hasVisualOverflow: childExtent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0, ); setChildParentData(child, constraints, geometry); } } 複製代碼
咱們把它放到 CustomScrollView 中:
eturn Scaffold( body: CustomScrollView( slivers: <Widget>[ CustomSliverWidget( child: Container( color: Colors.red, height: 100, child: Center( child: Text("CustomSliver"), ), )), _buildListView(), ], )); 複製代碼
效果以下:
咱們修改 paintOrigin 爲 10 的話,發現 CustomSliverWidget 的 layout 位置沒有變,但繪製的起始點下移了 10 px,而且它下一個的 Sliver - item0 的 layout 沒有被影響,可是 paint 時被遮住了一部分: 再作一個簡單的修改,將 sliver 的繪製起始位置改成滑動的偏移量:geometry = SliverGeometry( ... paintOrigin: constraints.scrollOffset, visible: true, ); 複製代碼
此時你會發現 CustomSliver 能夠固定在頭部:
咱們嘗試修改 paintExtrent 以下:
geometry = SliverGeometry( //將繪製範圍改成 sliver 的高度 paintExtent: childExtent, ... ); 複製代碼在滑動的過程,CustomSliver 只是繪製變了,layout 沒有變,致使下面 item0 沒有被滑動,這是由於 layoutExtent 默認等於 paintExtent,咱們將 paintExtent 賦值了常量,滑動過程當中只有 paintOrigin 在改變,layout 的初始位置和高度並無改變,它會一直佔據着位置。
接下來咱們再作一個簡單的下拉刷新 Widget,效果很簡單,下拉的時候顯示,釋放的時候縮回:
class CustomRefreshWidget extends SingleChildRenderObjectWidget { const CustomRefreshWidget({Key key, Widget child}) : super(key: key, child: child); @override RenderObject createRenderObject(BuildContext context) { return SimpleRefreshSliver(); } } /// 一個簡單的下拉刷新 Widget class SimpleRefreshSliver extends RenderSliverSingleBoxAdapter { @override void performLayout() { ... final bool active = constraints.overlap < 0.0; /// 頭部滑動的距離 final double overscrolledExtent = constraints.overlap < 0.0 ? constraints.overlap.abs() : 0.0; double layoutExtent = child.size.height; print("overscrolledExtent:${overscrolledExtent - layoutExtent}"); child.layout( constraints.asBoxConstraints( maxExtent: layoutExtent + overscrolledExtent, ), parentUsesSize: true, ); if (active) { geometry = SliverGeometry( scrollExtent: layoutExtent, /// 繪製起始位置 paintOrigin: min(overscrolledExtent - layoutExtent, 0), paintExtent: max(max(child.size.height, layoutExtent) ,0.0,), maxPaintExtent: max(max(child.size.height, layoutExtent) ,0.0,), /// 佈局佔位 layoutExtent: min(overscrolledExtent, layoutExtent), ); } else { /// 若是不想顯示能夠直接設置爲 zero geometry = SliverGeometry.zero; } setChildParentData(child, constraints, geometry); } } 複製代碼
能夠看到有3個關鍵的參數
items 的 top 與屏幕頂部的距離就是 constraints.overlap,它是一個小於等於 0 的值。
經常使用的 List 以下,咱們按照它包裹的內容分紅了 3 類:
ListView.builder( itemCount: 50, itemBuilder: (context,index) { return Container( color: ColorUtils.randomColor(), height: 50, ); } 複製代碼
CustomScrollView(
slivers: <Widget>[
SliverAppBar(...),
SliverToBoxAdapter(
child:ListView(...),
),
SliverList(...),
SliverGrid(...),
],
)
複製代碼
NestedScrollView 其實裏面是一個CustomScrollView,它的 headers 是 Sliver 的數組,body是被包裹在 SliverFillRemaining 中的,body 能夠接受 Box。
NestedScrollView( headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return <Widget>[ SliverAppBar( expandedHeight: 100, pinned: true, title: Text("Nest"), ), SliverToBoxAdapter( child: Text("second bar"), ) ]; }, body: ListView.builder( itemCount: 20, itemBuilder: (BuildContext context, int index) { return Text("item: $index"); }), ); 複製代碼
若是直接使用 ListView 嵌套 ListView 會報錯:
Vertical viewport was given unbounded height.
大體意思是在 layout 階段父 Listview 不能判斷子 Listview 的高度,這個錯誤能夠經過設置內部的 Listview 的 shrinkWrap = true 來修正(shrinkWrap = true 表明 ListView 的高度等於它 content 的高度)。
ListView.builder( itemCount: 20, itemBuilder: (BuildContext context, int index) { return ListView.builder( shrinkWrap: true, itemCount: 5, itemBuilder: (BuildContext context, int index) { return Text("item: $index"); }); }); 複製代碼
可是這樣作的話性能會比較差,由於內部的列表每次都要計算出全部 content 的高度,這個時候使用 CustomScrollView 更爲合適:
CustomScrollView( slivers: <Widget>[ SliverList( delegate: SliverChildBuilderDelegate( (context, index) => Container(...), childCount: 50) ), SliverList( delegate: SliverChildBuilderDelegate( (context, index) => Container(...), childCount: 50) ) ], ); 複製代碼
CustomScrollView 可讓它內部的 Slivers 進行聯動,好比作一個可伸縮的 TitleBar 、中間區域能夠固定的 header、下拉刷新組件等等。
Flutter 提供了不少的 Sliver 組件,下面咱們主要說一下它們的做用是什麼:
相似於 android 中 CollapsingToolbarLayout,能夠根據滑動作伸縮佈局,並提供了 actions,bottom 等提升效率的屬性。
用法和 ListView / GridView 基本一致。 此外,ListView = SliverList + Scrollable,也就是說 SliverList 不具有處理滑動事件的能力,因此它必須配合 CustomScrollView 來使用。
它比 SliverList 多了修飾詞 FixedExtent,意思是它的 item 在主軸方向上具備固定的高度/寬度。
設計它的緣由是在 item 高度/寬度全都同樣的場景下使用,它的效率比 SliverList 高,由於它不用經過 item 的 layout 過程就能夠知道每一個 item 的範圍。
在使用的時候必須傳入 itemExtent:
SliverFixedExtentList( itemExtent: 50.0, delegate: SliverChildBuilderDelegate( ... ); }, ), ) 複製代碼
SliverPersistentHeader( pinned: true, delegate: ..., ) 複製代碼
SliverPersistentHeaderDelegate 是一個抽象類,咱們須要本身實現它,它的實現很簡單,只有四個必需要實現的成員:
class CustomDelegate extends SliverPersistentHeaderDelegate { /// 最大高度 @override double get maxExtent => 100; /// 最小高度 @override double get minExtent => 50; /// shrinkOffset: 當前 sliver 頂部越過屏幕頂部的距離 /// overlapsContent: 下方是否還有 content 顯示 @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { return your widget ); } /// 是否須要刷新 @override bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) { return maxExtent != oldDelegate.maxExtent || minExtent != oldDelegate.minExtent; } } 複製代碼
在實際運用中沉浸式的設計是很常見的,使用 SliverPersistentHeaderDelegate 能夠輕鬆的實現沉浸式的效果:
它的實現原理就是根據 shrinkOffset 動態調整狀態欄的樣式和標題欄的顏色,實現代碼見下面的 沉浸式 Header。
將 BoxWidget 轉變爲 Sliver:因爲 CustomScrollView 只能接受 Sliver 類型的 child,因此不少經常使用的 Widget 沒法直接添加到 CustomScrollView 中,此時只須要將 Widget 用 SliverToBoxAdapter 包裹一下就能夠了。 最多見的使用就是 SliverList 不支持橫向模式,可是又沒法直接將 ListView 直接添加到 CustomScrollView 中,此時用 SliverToBoxAdapter 包裹一下:
CustomScrollView( slivers: <Widget>[ SliverToBoxAdapter( child: _buildHorizonScrollView(), ), ], )); Widget _buildHorizonScrollView() { return Container( height: 50, child: ListView.builder( scrollDirection: Axis.horizontal, primary: false, shrinkWrap: true, itemCount: 15, itemBuilder: (context, index) { return Container( color: ColorUtils.randomColor(), width: 50, height: 50, ); }), ); } 複製代碼
能夠用在 CustomScrollView 中的 Padding。 須要注意的是不要用它來包裹 SliverPersistentHeader ,由於它會使 SliverPersistentHeader 的 pinned 失效,若是 SliverPersistentHeader 非要使用 Padding 效果,能夠在 delegate 內部使用 Padding。
SliverPadding( padding: EdgeInsets.symmetric(horizontal: 16), sliver: SliverPersistentHeader( pinned: true, floating: false, delegate: Delegate(), ), ) 複製代碼
class Delegate extends SliverPersistentHeaderDelegate { @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) => Padding( padding: EdgeInsets.symmetric(horizontal: 16), child: Container( color: Colors.yellow, ), ); ... } 複製代碼
用法和 SafeArea 一致。
能夠填充屏幕剩餘控件的 Sliver。
import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class GradientSliverHeaderDelegate extends SliverPersistentHeaderDelegate { final double collapsedHeight; final double expandedHeight; final double paddingTop; final String coverImgUrl; final String title; GradientSliverHeaderDelegate({ this.collapsedHeight, this.expandedHeight, this.paddingTop, this.coverImgUrl, this.title, }); @override double get minExtent => this.collapsedHeight + this.paddingTop; @override double get maxExtent => this.expandedHeight; @override bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) { return true; } Color makeStickyHeaderBgColor(shrinkOffset) { final int alpha = (shrinkOffset / (this.maxExtent - this.minExtent) * 255) .clamp(0, 255) .toInt(); return Color.fromARGB(alpha, 255, 255, 255); } Color makeStickyHeaderTextColor(shrinkOffset) { if (shrinkOffset <= 50) { return Colors.white; } else { final int alpha = (shrinkOffset / (this.maxExtent - this.minExtent) * 255) .clamp(0, 255) .toInt(); return Color.fromARGB(alpha, 0, 0, 0); } } Brightness getStatusBarTheme(shrinkOffset) { return shrinkOffset <= 50 ? Brightness.light : Brightness.dark; } @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { SystemUiOverlayStyle systemUiOverlayStyle = SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: getStatusBarTheme(shrinkOffset)); SystemChrome.setSystemUIOverlayStyle(systemUiOverlayStyle); return Container( height: this.maxExtent, width: MediaQuery.of(context).size.width, child: Stack( fit: StackFit.expand, children: <Widget>[ // 背景圖 Container( child: Image.asset( coverImgUrl, fit: BoxFit.cover, )), // 收起頭部 Positioned( left: 0, right: 0, top: 0, child: Container( color: this.makeStickyHeaderBgColor(shrinkOffset), // 背景顏色 child: SafeArea( bottom: false, child: Container( height: this.collapsedHeight, child: Center( child: Text( this.title, style: TextStyle( fontSize: 20, fontWeight: FontWeight.w500, color: this .makeStickyHeaderTextColor(shrinkOffset), // 標題顏色 ), ), )), ), ), ), ], ), ); } } 複製代碼
class CustomRefreshWidget extends SingleChildRenderObjectWidget { const CustomRefreshWidget({Key key, Widget child}) : super(key: key, child: child); @override RenderObject createRenderObject(BuildContext context) { return SimpleRefreshSliver(); } } /// 一個簡單的下拉刷新 Widget class SimpleRefreshSliver extends RenderSliverSingleBoxAdapter { @override void performLayout() { if (child == null) { geometry = SliverGeometry.zero; return; } child.layout(constraints.asBoxConstraints(), parentUsesSize: true); double childExtent; switch (constraints.axis) { case Axis.horizontal: childExtent = child.size.width; break; case Axis.vertical: childExtent = child.size.height; break; } assert(childExtent != null); final double paintedChildSize = calculatePaintOffset(constraints, from: 0.0, to: childExtent); assert(paintedChildSize.isFinite); assert(paintedChildSize >= 0.0); final bool active = constraints.overlap < 0.0; final double overscrolledExtent = constraints.overlap < 0.0 ? constraints.overlap.abs() : 0.0; double layoutExtent = child.size.height; print("overscrolledExtent:${overscrolledExtent - layoutExtent}"); child.layout( constraints.asBoxConstraints( maxExtent: layoutExtent // Plus only the overscrolled portion immediately preceding this // sliver. + overscrolledExtent, ), parentUsesSize: true, ); if (active) { geometry = SliverGeometry( scrollExtent: layoutExtent, /// 繪製起始位置 paintOrigin: min(overscrolledExtent - layoutExtent, 0), paintExtent: max(max(child.size.height, layoutExtent) ,0.0,), maxPaintExtent: max(max(child.size.height, layoutExtent) ,0.0,), /// 佈局佔位 layoutExtent: min(overscrolledExtent, layoutExtent), ); } else { /// 若是不想顯示能夠直接設置爲 zero geometry = SliverGeometry.zero; } setChildParentData(child, constraints, geometry); } } 複製代碼
使用:
@override Widget build(BuildContext context) { return Scaffold( body: CustomScrollView( /// android 須要設置彈簧效果 overlap 纔會起做用 physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), CustomRefreshWidget( child: Container( height: 100, color: Colors.purple, child: Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: <Widget>[ Text( "RefreshWidget", style: TextStyle(color: Colors.white), ), Padding( padding: EdgeInsets.only(left: 10.0), child: CupertinoActivityIndicator(), ) ], ), ), ), ... _buildListView(), ], )); } 複製代碼