Flutter中可滚动布局基本都来自Sliver
模型,原理和安卓传统UI的ListView、RecyclerView类似,滚动布局里面的每个子组件的样式往往是相同的,由于组件占用内存较大,所以在内存上我们可以缓存有限个组件,滚动布局时仅仅刷新组件的数据,来达到滚动布局存放无限个子组件的目标。另一方面,内容被渲染到了lazy widget里,也就是SliverList and SliverGrid (以及它们的变种 SliverFixedExtentList 和 SliverAnimatedGrid)。这些组件确保只有列表中可见部分会被布局和渲染。ListView 和 GridView只是CustomScrollView 和 SliverList 或 SliverGrid组合在一起,方便我们使用的一个封装组件而已。它们允许我们直接使用box widget。而CustomScrollView的slivers属性明确要求我们使用sliver widget。但是sliver widget也只是box widget的包裹,后者才是真正用于渲染的。按照列表内容的差异,我们可以将scrolling widget分为以下三类:
?sliver widget是可滚动列表中的一部分,它是面向viewport进行布局的。
ViewPort 有一些重要属性:
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;
}
我们看到center是一个key,表示从哪个sliver开始绘制,绘制的起点是一条zero基准线。这条基准线相对于视口的位置叫anchor。视口相对于整个滚动列表的位置叫offset。如果子元素进入了视口上下cacheExtent的区域就会被预先加载。
以上图为例,center=sliver1,anchor=0.2,此时sliver的top=0.2 * viewport.height,所以前面刚好能展示sliver0,也就是浅蓝色区域会滚到整个列表的开始位置。再比如,center=sliver1,anchor=0.4,此时sliver0上面会空出一条sliver大小的空间。
和 Box 布局使用 BoxConstraints 作为约束类似,Sliver 布局采用 SliverConstraints 作为约束,但相对于 Box 要复杂的多,可以理解为 SliverConstraints 描述了 Viewport 和它内部的 Slivers 之间的布局信息:
class SliverConstraints extends Constraints {
//主轴方向
AxisDirection? axisDirection;
//Sliver 新数据沿主轴的哪个方向插入?枚举类型,正向或反向
GrowthDirection? growthDirection;
//用户滑动方向
ScrollDirection? userScrollDirection;
//当前Sliver理论上(可能会固定在顶部)已经滑出可视区域的总偏移
double? scrollOffset;
//当前Sliver之前的Sliver占据的总高度,因为列表是懒加载,如果不能预估时,该值为double.infinity
double? precedingScrollExtent;
//上一个 sliver 覆盖当前 sliver 的大小,通常在 sliver 是 pinned/floating
//或者处于列表头尾时有效。
double? overlap;
//当前Sliver在Viewport中的最大可以绘制的区域。
//绘制如果超过该区域会比较低效(因为不会显示)
double? remainingPaintExtent;
//纵轴的长度;如果列表滚动方向是垂直方向,则表示列表宽度。
double? crossAxisExtent;
//纵轴方向
AxisDirection? crossAxisDirection;
//Viewport在主轴方向的长度;如果列表滚动方向是垂直方向,则表示列表高度。
double? viewportMainAxisExtent;
//Viewport 预渲染区域的起点[-Viewport.cacheExtent, 0]
double? cacheOrigin;
//Viewport加载区域的长度,范围:
//[viewportMainAxisExtent,viewportMainAxisExtent + Viewport.cacheExtent*2]
double? remainingCacheExtent;
}
在Viewport 预渲染区域中的起点,位于[-Viewport.cacheExtent, 0]之间
上图中的 sliver1 会被 SliverAppBar(pinned = true)遮住,遮住的大小就是 overlap,此时 overlap 会一直大于 0,如果设置像 iOS bouncing 那样的滑动效果,那么当 list 滚动到顶部继续滑动的时候 overlap 会小于 0(此刻并没有东西遮盖 sliver1,而是 sliver1 的 top 和 viewport 的 top 有间距)。
当前 Sliver 在 Viewport 中的最大可以绘制的区域。当viewport对sliver布局的时候,会将remainingPaintExtent减去这个sliver的paintExtent后,作为传入下一个sliver的remainingPaintExtent。remainingPaintExtent的初始值是viewportMainAxisExtent。
由此,我们可以计算当前 sliver 距离顶部的距离:
//如果大于0,表示当前 sliver 距离顶部的高度为 topOffset。若已经到达顶部或超出顶部,则该值始终等于 0。此时超出的距离可参考 scrollOffset。
topOffset = viewportMainAxisExtent - remainingPaintExtent;
当前sliver无论实际需要绘制多大的区域,最终能绘制到viewport的大小,不会超过remainingPaintExtent:
// paintExtent 通常需要这样处理一下,避免超出 remainingPaintExtent
paintExtent = min(paintExtent, constraints.remainingPaintExtent);
scrollOffset 属性表示滚动滑出Viewport边界的距离,类似于 web 的 scrollTop 属性。一般来说表示组件的上边界离开 viewport 顶部的长度,未到达顶部之前都是 0。
Viewport 通过 SliverConstraints 告知它内部的 sliver 自己的约束信息,比如还有多少空间可用、offset 等,那么Sliver 则通过 SliverGeometry 反馈给 Viewport 需要占用多少空间量。
const SliverGeometry({
//Sliver在主轴方向预估长度,大多数情况是固定值,用于计算sliverConstraints.scrollOffset
this.scrollExtent = 0.0,
this.paintExtent = 0.0, // 可视区域中的绘制长度
this.paintOrigin = 0.0, // 绘制的坐标原点,相对于自身布局位置
//在 Viewport中占用的长度;如果列表滚动方向是垂直方向,则表示列表高度。
//范围[0,paintExtent]
double? layoutExtent,
this.maxPaintExtent = 0.0,//最大绘制长度
this.maxScrollObstructionExtent = 0.0,
double? hitTestExtent, // 点击测试的范围
bool? visible,// 是否显示
//是否会溢出Viewport,如果为true,Viewport便会裁剪
this.hasVisualOverflow = false,
//scrollExtent的修正值:layoutExtent变化后,为了防止sliver突然跳动(应用新的layoutExtent)
//可以先进行修正。
this.scrollOffsetCorrection,
double? cacheExtent, // 在预渲染区域中占据的长度
})
当该值小于 0 时,当前 sliver 的整体起始位置会向上偏移 paintOrigin.abs() 的长度。如果每次下拉 x 的长度,paintOrigin 也向上移动 x 的距离,则 sliver 相对静止,由此可实现 pinned 效果。
布局时占用的高度,取值范围 [0, paintExtent]。即 layoutExtent 须小于等于 paintExtent
(当前绘制的高度,一般是等于)。
当 layoutExtent 小于 paintExtent 时,则一部分高度会被下一个 sliver 顶上。
示例 2:
说明:蓝色块高度是 100,但是占据高度只有 30,导致红色块向上顶了 70的高度。紫色部分是下方的红色与上方的蓝色重叠的区域
当 visible 为 false 时会影响子节点的显示。在示例中,只占据空间(占据高度 layoutExtent),而不显示界面。即不影响布局。
效果:
说明:中间 30 的高度为原蓝色块占据的空间
?
可滚动的范围。一般来说,对于 ListView,在 sliver 上边界滚动到顶部之前 paintExtent 等于 layoutExtent 都等于 scrollExtent,到达顶部后慢慢变小,直到变为 0。而 scrollExtent 一直不变。
注:如果 layoutExtent 不慢慢变小,即保持不变并且大于 0,则在当前 sliver 滚动到顶部后还可以继续滚动 scrollExtent 的长度(除非 scrollExtent 也等于 0),然后再执行下一个 sliver 的滚动。
示例 3:
说明:由于 layoutExtent 与 scrollExtent 都一直不变,并且不等于 0。蓝色 sliver 向上滚动到 Viewport 顶部后,还可以继续滚动 100 的高度,当这 100 也滚完了,下一个 sliver 才开始滚动。
RenderViewport 在 layout 它内部的 slivers 的过程如下:
这个 layout 过程是一个自上而下的线性过程:
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 是一个可以固定/悬浮的 header,它可以设置在列表的任意位置,显示的内容需要设置 SliverPersistentHeaderDelegate。
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。