flutter 效果实现 —— sliver 固定
效果:
说明:绿色块在向上滑动,距离顶部 103 的高度(即 AppBar 下面)时固定
注:示例4 有官方组件 PinnedHeaderSliver( flutter 3.4)
示例 1:
解决问题的关键是修正 paintOffset,以使最终的 offset 在 pinned 位置固定不变。缺点是由于该示例的 origin 等于 0,会导致其后面的一个 sliver 被覆盖。
class PinnedSliverPage extends StatefulWidget {
const PinnedSliverPage({Key? key}) : super(key: key);
@override
State<PinnedSliverPage> createState() => _PinnedSliverPageState();
}
class _PinnedSliverPageState extends State<PinnedSliverPage> {
@override
Widget build(BuildContext context) {
print('topBar: ${MediaQuery.of(context).padding.top}');
return Scaffold(
backgroundColor: Colors.blueGrey,
body: CustomScrollView(
physics:
AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()),
slivers: [
SliverAppBar(
title: Text("Pinned Sliver"),
pinned: true,
backgroundColor: Colors.orange.withOpacity(0.8),
),
SliverToBoxAdapter(
child: Container(
alignment: Alignment.center,
height: 200,
color: Colors.yellow,
child: Text("200"),
),
),
//距离顶部 103时,开始固定
SliverPinHeader(
pinnedOffset: 103,
child: Container(
alignment: Alignment.center,
color: Colors.green.withOpacity(0.8),
height: 300,
child: Text("300"),
),
),
SliverToBoxAdapter(
child: Container(
alignment: Alignment.center,
height: 1200,
color: Colors.red,
child: Text("1200"),
),
)
],
));
}
}
class SliverPinHeader extends SingleChildRenderObjectWidget {
const SliverPinHeader({
Key? key,
required this.pinnedOffset,
required Widget child,
}) : super(child: child);
final double pinnedOffset;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderSliverPinHeader(pinnedOffset: pinnedOffset);
}
@override
void updateRenderObject(BuildContext context, RenderSliverPinHeader renderObject) {
renderObject.pinnedOffset = pinnedOffset;
}
}
class RenderSliverPinHeader extends RenderSliverSingleBoxAdapter {
RenderSliverPinHeader({required double pinnedOffset}): _pinnedOffset = pinnedOffset;
late double _pinnedOffset = 0;
set pinnedOffset(double value) {
if (_pinnedOffset == value) {
return;
}
_pinnedOffset = value;
markNeedsLayout();
}
@override
void setupParentData(RenderObject child) {
if (child.parentData is! SliverPhysicalParentData) {
child.parentData = SliverPhysicalParentData();
}
}
@override
void performLayout() {
if (child == null) {
geometry = SliverGeometry.zero;
return;
}
// 1.对子节点进行约束 SliverConstraints
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;
}
final paintedChildSize =
calculatePaintOffset(constraints, from: 0.0, to: childExtent);
final double cacheExtent =
calculateCacheOffset(constraints, from: 0.0, to: childExtent);
// 2.上报当前节点的布局信息给 viewport
geometry = SliverGeometry(
// 默认 paintExtent 大于 0时才显示当前 sliver。手动设置 visible: true 则总是可见,不受 paintExtent 影响。
// visible: paintExtent > 0.0
// 可以调整此值为 -constraints.scrollOffset,来影响 paintChild.offset。
// paintOrigin: 0
// 通常等于子组件的高度,并且不随着滚动而改变
scrollExtent: childExtent,
// 通过等于页面上实际显示的长度。滚动到顶部后,开始变小,直到等于 0。手动设置的话,须小于 maxPaintExtent
paintExtent: childExtent,
// 注:布局高度要逐渐变小,否则下一个 sliver 滚到顶部会有停顿(可尝试注释掉这段代码)。
layoutExtent: paintedChildSize,
cacheExtent: cacheExtent,
// 最大可绘制长度,常等于 childExtent。取值必须小于 constraints.remainingPaintExtent。
maxPaintExtent: childExtent,
);
// 3.设置并保存 paintOffset 到 parentData 中,待稍后确定子节点位置(offset)
// 默认等于 (0,0)
// setChildParentData(child!, constraints, geometry!);
final SliverPhysicalParentData childParentData =
child!.parentData! as SliverPhysicalParentData;
final topOffset =
constraints.viewportMainAxisExtent - constraints.remainingPaintExtent;
final offset = topOffset + (geometry?.paintOrigin ?? 0);
if (offset < _pinnedOffset) {
final paintOffset = _pinnedOffset - offset;
childParentData.paintOffset = Offset(0.0, paintOffset);
} else {
childParentData.paintOffset = Offset(0.0, -constraints.scrollOffset);
}
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null && geometry!.visible) {
final SliverPhysicalParentData childParentData =
child!.parentData! as SliverPhysicalParentData;
print("offset: $offset, paintOffset: ${childParentData.paintOffset}");
// 4.绘制子节点
// topOffset = constraints.viewportMainAxisExtent - constraints.remainingPaintExtent;
//其中 offset = topOffset(sliver 到达顶部后等于 0) + paintOrigin(默认等于0)
//其中 paintOffset(默认等于0)
//即到达顶部后,如果两者都等于0,并且 visible 不等于 false,则 child 保持当前位置不变。
context.paintChild(child!, offset + childParentData.paintOffset);
}
}
}
示例2:
亦可调整 paintOrign 达到同样的效果。(过程太繁琐,不推荐使用。但相关注释可以作参考)
class RenderSliverStillHeader extends RenderSliverSingleBoxAdapter {
RenderSliverStillHeader({double? pinnedOffset})
: _pinnedOffset = pinnedOffset;
final double? _pinnedOffset;
@override
void setupParentData(RenderObject child) {
if (child.parentData is! SliverPhysicalParentData) {
child.parentData = SliverPhysicalParentData();
}
}
@override
void performLayout() {
if (child == null) {
geometry = SliverGeometry.zero;
return;
}
// 1.对子节点进行约束 SliverConstraints
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;
}
final paintedChildSize =
calculatePaintOffset(constraints, from: 0.0, to: childExtent);
final double cacheExtent =
calculateCacheOffset(constraints, from: 0.0, to: childExtent);
///策略:修改 origin, 让 sliver与相应方向的offset抵消,即保持相对静止
/// 下拉:
///
/// 如果是回弹:
/// 因为 sliver 未到达顶部,所以 scrollOffset 等于 0
/// overScroll 是过量下拉的高度大于 0,回弹后等于 0
/// layoutExtent 代表 sliver 占据的高度,下拉时子节点的大小固定不变,如当前 child 长度是 300
/// paintExtent 表示当前可绘制的长度,等于 layoutExtent + overScroll
///
///
/// 上拉:
///
/// overScroll 等于 0
///
/// 在 sliver 未到达顶部之前:
/// scrollOffset 等于 0
/// 由于 remainingPaintExtent = viewportMainAxisExtent(表示 viewport 总滚动高度) - topOffset(上方 sliver 组件占据的滚动高度, 包括 overScroll的高度,如果有的话)
/// 因此,当前 sliver 距离顶部的高度 topOffset = viewportMainAxisExtent - remainingPaintExtent。
///
/// 经尝试发现:过量下拉回弹的高度也可通过 topOffset = viewportMainAxisExtent - remainingPaintExtent 计算,此时它的值是负数。
///
/// 在 sliver 到达顶部之后:
/// topOffset = 0
/// scrollOffset 大于 0
///
/// 另外:precedingScrollExtent 表示当前Sliver之前的Sliver占据的总高度,也可表示当前 sliver 的初始位置。但如果列表是懒加载,如果不能预估时,
/// 该值为 double.infinity。
///
/// 分析如何使当前 sliver 保持相对静止:由于当前 sliver 是在可视区域里,只需要在当前位置保持不动即可,即固定距离等于初始位置,等于 precedingScrollExtent
///
/// 当组件向下滚动时,需要将 paintOrigin 向上偏移,偏移的距离是 -overScroll
/// 当组件向上滚动时,若当前 sliver 距离顶部的距离 topOffset 小于初始位置 precedingScrollExtent,即表示当前组件向上滚动了。
/// 那么,设置 paintOrigin 向下偏移相应的距离即可。偏移的距离是 precedingScrollExtent - topOffset
/// 滚动超过顶部后,topOffset 等于 0,继续偏移的距离是 scrollOffset。因此,总的偏移距离是 precedingScrollExtent - topOffset + scrollOffset
///
double overScroll = constraints.overlap < 0 ? constraints.overlap.abs() : 0;
print('layoutExtent: ${geometry?.layoutExtent}, '
'paintExtent: ${geometry?.paintExtent}, '
'overScroll: $overScroll, '
'scrollOffset: ${constraints.scrollOffset}, ');
print('remainingPaintExtent: ${constraints.remainingPaintExtent},'
'viewportMainAxisExtent: ${constraints.viewportMainAxisExtent},'
'topOffset: ${constraints.viewportMainAxisExtent - constraints.remainingPaintExtent},'
'precedingScrollExtent: ${constraints.precedingScrollExtent}');
//固定的位置
double? pinnedOffset = _pinnedOffset;
pinnedOffset ??= constraints.precedingScrollExtent;
final topOffset =
constraints.viewportMainAxisExtent - constraints.remainingPaintExtent;
double origin = 0;
if (topOffset <= pinnedOffset!) {
origin = pinnedOffset - topOffset +
constraints.scrollOffset;
print('origin: $origin');
}
// 2.上报当前节点的布局信息给 viewport
geometry = SliverGeometry(
// 可以调整此值为 -constraints.scrollOffset,来影响 paintChild.offset。
paintOrigin: origin,
// 默认 paintExtent 大于 0时才显示当前 sliver。手动设置 visible: true 则总是可见,不受 paintExtent 影响。
// visible: paintExtent > 0.0
visible: true,
// 通常等于子组件的高度,并且不随着滚动而改变
scrollExtent: childExtent,
// 通过等于页面上实际显示的长度。滚动到顶部后,开始变小,直到等于 0。手动设置的话,须小于 maxPaintExtent
// 因为有限制 paintOrigin + paintExtent <= remainingPaintExtent。而 paintOrigin 越来越大(因为 scrollOffset 变大),所以需要使 paintExtent 变小。
paintExtent: paintedChildSize,
layoutExtent: paintedChildSize,
cacheExtent: cacheExtent,
// 最大可绘制长度,常等于 childExtent。取值必须小于 constraints.remainingPaintExtent。
maxPaintExtent: childExtent,
);
// 3.保存 paintOffset 到 parentData 中,待稍后确定子节点位置(offset)
//如果不设置,则 paintOffset 默认等于 (0,0)
setChildParentData(child!, constraints, geometry!);
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null && geometry!.visible) {
final SliverPhysicalParentData childParentData =
child!.parentData! as SliverPhysicalParentData;
print("offset: $offset, paintOffset: ${childParentData.paintOffset}");
// 4.绘制子节点
// topOffset = constraints.viewportMainAxisExtent - constraints.remainingPaintExtent;
//其中 offset = topOffset(sliver 到达顶部后等于 0) + paintOrigin(默认等于0)
//其中 paintOffset(默认等于0)
//即到达顶部后,如果两者都等于0,并且 visible 不等于 false,则 child 保持当前位置不变。
context.paintChild(child!, offset + childParentData.paintOffset);
}
}
}
示例 3:
移除示例 2中
setChildParentData(child!, constraints, geometry!);
,直接设置 origin 等于固定位置。
class SliverFixedHeader extends SingleChildRenderObjectWidget {
const SliverFixedHeader({
Key? key,
required this.pinnedOffset,
required Widget child,
}) : super(child: child);
final double pinnedOffset;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderSliverFixedHeader(pinnedOffset: pinnedOffset);
}
@override
void updateRenderObject(
BuildContext context, RenderSliverFixedHeader renderObject) {
renderObject.pinnedOffset = pinnedOffset;
}
}
class RenderSliverFixedHeader extends RenderSliverSingleBoxAdapter {
RenderSliverFixedHeader({required double pinnedOffset})
: _pinnedOffset = pinnedOffset;
late double _pinnedOffset = 0;
set pinnedOffset(double value) {
if (_pinnedOffset == value) {
return;
}
_pinnedOffset = value;
markNeedsLayout();
}
@override
void setupParentData(RenderObject child) {
if (child.parentData is! SliverPhysicalParentData) {
child.parentData = SliverPhysicalParentData();
}
}
@override
void performLayout() {
if (child == null) {
geometry = SliverGeometry.zero;
return;
}
// 1.对子节点进行约束 SliverConstraints
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;
}
final paintedChildSize =
calculatePaintOffset(constraints, from: 0.0, to: childExtent);
final double cacheExtent =
calculateCacheOffset(constraints, from: 0.0, to: childExtent);
final topOffset =
constraints.viewportMainAxisExtent - constraints.remainingPaintExtent;
double origin = 0;
if (topOffset <= _pinnedOffset) {
origin = _pinnedOffset - topOffset;
}
// 2.上报当前节点的布局信息给 viewport
geometry = SliverGeometry(
// visible: paintExtent > 0.0
paintOrigin: origin,
scrollExtent: childExtent,
paintExtent: childExtent,
layoutExtent: paintedChildSize,
cacheExtent: cacheExtent,
maxPaintExtent: childExtent,
);
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null && geometry!.visible) {
final SliverPhysicalParentData childParentData =
child!.parentData! as SliverPhysicalParentData;
// 4.绘制子节点
// topOffset = constraints.viewportMainAxisExtent - constraints.remainingPaintExtent;
//其中 offset = topOffset(sliver 到达顶部后等于 0) + paintOrigin(默认等于0)
//其中 paintOffset(默认等于0)
//即到达顶部后,如果两者都等于0,并且 visible 不等于 false,则 child 保持当前位置不变。
context.paintChild(child!, offset + childParentData.paintOffset);
}
}
@override
double childMainAxisPosition(RenderBox child) {
return 0;
}
}
示例 4:
在使用中,常常直接在导航栏下面固定,而非手动设置固定距离。为此只需要将 paintOrigin 设置为 overlap 即可
class SliverPinnedHeader extends SingleChildRenderObjectWidget {
const SliverPinnedHeader({
Key? key,
required Widget child,
}) : super(child: child);
@override
RenderObject createRenderObject(BuildContext context) {
return RenderSliverPinnedHeader();
}
}
class RenderSliverPinnedHeader extends RenderSliverSingleBoxAdapter {
@override
void performLayout() {
if (child == null) {
geometry = SliverGeometry.zero;
return;
}
// 1.对子节点进行约束 SliverConstraints
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;
}
final paintedChildSize =
calculatePaintOffset(constraints, from: 0.0, to: childExtent);
final double cacheExtent =
calculateCacheOffset(constraints, from: 0.0, to: childExtent);
print('constraints.overlap: ${constraints.overlap}');
final paintExtent = math.min(
childExtent, constraints.remainingPaintExtent - constraints.overlap);
// 2.上报当前节点的布局信息给 viewport
geometry = SliverGeometry(
// visible: paintExtent > 0.0
//解决问题的关键是这个:将 origin 设置为被覆盖的高度
paintOrigin: constraints.overlap,
scrollExtent: childExtent,
paintExtent: paintExtent,
layoutExtent: paintedChildSize,
cacheExtent: cacheExtent,
maxPaintExtent: childExtent,
);
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null && geometry!.visible) {
final SliverPhysicalParentData childParentData =
child!.parentData! as SliverPhysicalParentData;
// 4.绘制子节点
// topOffset = constraints.viewportMainAxisExtent - constraints.remainingPaintExtent;
//其中 offset = topOffset(sliver 到达顶部后等于 0) + paintOrigin(默认等于0)
//其中 paintOffset(默认等于0)
//即到达顶部后,如果两者都等于0,并且 visible 不等于 false,则 child 保持当前位置不变。
context.paintChild(child!, offset + childParentData.paintOffset);
}
}
@override
double childMainAxisPosition(RenderBox child) {
return 0;
}
}