列表組件在移動端上尤其重要,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;
...
}
複製代碼
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(),
],
));
複製代碼
效果以下:
geometry = SliverGeometry(
...
paintOrigin: constraints.scrollOffset,
visible: true,
);
複製代碼
此時你會發現 CustomSliver 能夠固定在頭部:
咱們嘗試修改 paintExtrent 以下:
geometry = SliverGeometry(
//將繪製範圍改成 sliver 的高度
paintExtent: childExtent,
...
);
複製代碼
接下來咱們再作一個簡單的下拉刷新 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(),
],
));
}
複製代碼