今天这个主题看着是不是有点抽象?又是列表嵌套?之前不是分享过《 ListView 和 PageView 的各种花式嵌套》了么?那这次的自适应大小布局支持有什么不同?
算是某些奇特的场景下才会需要。
首先我们看下面这段代码,基本逻辑就是:我们希望 vertical
的 ListView
里每个 Item 都是根据内容自适应大小,并且 Item 会存在有 horizontal
的 ListView
这样的 child。
horizontal
的 ListView
我们也希望它能够根据自己的 children
去自适应大小。那么你觉得这段代码有什么问题?它能正常运行吗?
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: new Text(""),
),
extendBody: true,
body: Container(
color: Colors.white,
child: ListView(
children: [
ListView(
scrollDirection: Axis.horizontal,
children: List<Widget>.generate(50, (index) {
return Padding(
padding: EdgeInsets.all(2),
child: Container(
color: Colors.blue,
child: Text(List.generate(
math.Random().nextInt(10), (index) => "TEST\n")
.toString()),
),
);
}),
),
Container(
height: 1000,
color: Colors.green,
),
],
),
),
);
}
答案是不能,因为这段代码里 vertical
的 ListView
嵌套了 horizontal
的 ListView
,而横向的 ListView
并没有指定高度,并且垂直方向的 ListView
也没有指定 itemExtent
,所以我们会得到如下图所示的错误:
为什么会有这样的问题,简单说一下,我们都知道 Flutter 是从上往下传递约束,从上往上返回 Size
的一个布局过程,也就是需要 child 通过通过 parent 的约束来决定自己的大小,然后 parent 根据 child 返回的 Size
决定自己的尺寸。
对这部分感兴趣的可以看 《带你了解不一样的 Flutter》
但是对于可滑动控件来说有点特殊,因为可滑动控件在其滑动方向的主轴上,理论是需要「无限大」的,所以对于可滑动控件来说,就需要有一个「窗口」的固定大小,也就是 ViewPort
这个「窗口」需要有一个主轴方向的大小。
比如 ListView
,一般情况下就是有一个 ViewPort
,然后内部的 SliverList
构建一个列表,然后通过手势在 ViewPort
「窗口」下相应产生移动,从而达到列表滑动的效果。
如果感兴趣可以看 《不一样角度带你了解 Flutter 中的滑动列表实现》
那么我们再回到上面 vertical
的 ListView
嵌套 horizontal
的 ListView
的问题:
- 因为垂直的
ListView
没有设置itemExtent
,所以它的每个 child 不会有一个固定高度,因为我们的需求是每个 Item 根据自己的需要自适应高度。 - 横向的
ListView
没有设置明确高度,作为 parent 的垂直ListView
高度理论又是「无限高」,所以横向的ListView
无法计算得到一个有效的高度。
另外,由于 ListView
不像 Row
/Column
等控件,它拥有的 children
理论也是「无限」的,并且没有展示的部分一般是不会布局和绘制,所以不能像 Row
/Column
一样计算出所有控件的高度之后,来决定自身的高度。
那么破解的方式有哪些呢?目前情况下可以提供两种解决方式。
SingleChildScrollView
如下代码所示,首先最简单的就是把横向的 ListView
替换成 SingleChildScrollView
,因为不同于 ListView
, SingleChildScrollView
只有一个 child ,所以它的 ViewPort
也比较特殊。
return Scaffold(
appBar: AppBar(
title: new Text("ControllerDemoPage"),
),
extendBody: true,
body: Container(
color: Colors.white,
child: ListView(
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List<Widget>.generate(50, (index) {
return Padding(
padding: EdgeInsets.all(2),
child: Container(
color: Colors.blue,
child: Text(List.generate(
math.Random().nextInt(10), (index) => "TEST\n")
.toString()),
),
);
}),
),
),
Container(
height: 1000,
color: Colors.green,
),
],
),
),
)
在 SingleChildScrollView
的 _RenderSingleChildViewport
里,布局时可以很简单的通过 child!.layout
之后得到 child 的大小,然后配合 Row
就计算出所有 child 的综合高度,这样可以实现横向的列表效果。
运行之后结果入下图所示,可以看到此时在垂直的 ListView
里,横向的 SingleChildScrollView
被正确渲染出来,但是此时出现「参差不齐」的高度布局。
如下代码所示,这时候我们只需要在 Row
嵌套一个 IntrinsicHeight
,就可以让其内部高度对齐,因为 IntrinsicHeight
在布局时会提前调用 child 的 getMaxIntrinsicHeight
获取 child 的高度,修改 parent 传递给 child 的约束信息。
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: IntrinsicHeight(
child: Row(
children: List<Widget>.generate(50, (index) {
return Padding(
padding: EdgeInsets.all(2),
child: Container(
alignment: Alignment.bottomCenter,
color: Colors.blue,
child: Text(List.generate(
math.Random().nextInt(10), (index) => "TEST\n")
.toString()),
),
);
}),
),
),
),
运行效果如下所示,可以看到此时所有横向 Item 的高度都一致,但是这个解决方法也有两个比较致命的问题:
SingleChildScrollView
里是通过Row
计算的高度,也就是布局时会需要一次性计算所有 child ,如果列表太长就会产生性能损耗IntrinsicHeight
推算布局的过程会比较费时,可能会到 O(N²),虽然 Flutter 里针对这部分计算结果做了缓存,但是不妨碍它的耗时。
UnboundedListView
第二个解决思路就是基于 ListView
去自定义,前面我们不是说 ListView
不会像 Row
那样去统计 children 的大小么?那我们完全可以自定义一个 UnboundedListView
来统计。
这部分思路最早来自 Github :https://gist.github.com/vejmartin/b8df4c94587bdad63f5b4ff111ff581c
首先我们基于 ListView
定义一个 UnboundedListView
,通过 mixin
的方式 override
对应的 Viewport
和 Sliver
,也就是:
- 把
buildChildLayout
里的SliverList
替换成我们自定义的UnboundedSliverList
- 把
buildViewport
里的Viewport
替换成我们自定义的UnboundedViewport
- 在
buildSlivers
里处理Padding
逻辑,把SliverPadding
替换为自定义的UnboundedSliverPadding
class UnboundedListView = ListView with UnboundedListViewMixin;
/// BoxScrollView 的基础上
mixin UnboundedListViewMixin on ListView {
Widget buildChildLayout(BuildContext context) {
return UnboundedSliverList(delegate: childrenDelegate);
}
Widget buildViewport(
BuildContext context,
ViewportOffset offset,
AxisDirection axisDirection,
List<Widget> slivers,
) {
return UnboundedViewport(
axisDirection: axisDirection,
offset: offset,
slivers: slivers,
cacheExtent: cacheExtent,
);
}
List<Widget> buildSlivers(BuildContext context) {
Widget sliver = buildChildLayout(context);
EdgeInsetsGeometry? effectivePadding = padding;
if (padding == null) {
final MediaQueryData? mediaQuery = MediaQuery.maybeOf(context);
if (mediaQuery != null) {
// Automatically pad sliver with padding from MediaQuery.
final EdgeInsets mediaQueryHorizontalPadding =
mediaQuery.padding.copyWith(top: 0.0, bottom: 0.0);
final EdgeInsets mediaQueryVerticalPadding =
mediaQuery.padding.copyWith(left: 0.0, right: 0.0);
// Consume the main axis padding with SliverPadding.
effectivePadding = scrollDirection == Axis.vertical
? mediaQueryVerticalPadding
: mediaQueryHorizontalPadding;
// Leave behind the cross axis padding.
sliver = MediaQuery(
data: mediaQuery.copyWith(
padding: scrollDirection == Axis.vertical
? mediaQueryHorizontalPadding
: mediaQueryVerticalPadding,
),
child: sliver,
);
}
}
if (effectivePadding != null)
sliver =
UnboundedSliverPadding(padding: effectivePadding, sliver: sliver);
return <Widget>[sliver];
}
}
接下来首先是实现 UnboundedViewport
,一样的套路:
- 首先基于
Viewport
的基础上,通过createRenderObject
将RenderViewPort
修改为我们的UnboundedRenderViewport
- 基于
RenderViewport
增加performLayout
和layoutChildSequence
的自定义逻辑,实际上就是增加一个unboundedSize
参数,这个参数通过 child 的RenderSliver
里去统计得到
class UnboundedViewport = Viewport with UnboundedViewportMixin;
mixin UnboundedViewportMixin on Viewport {
RenderViewport createRenderObject(BuildContext context) {
return UnboundedRenderViewport(
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection ??
Viewport.getDefaultCrossAxisDirection(context, axisDirection),
anchor: anchor,
offset: offset,
cacheExtent: cacheExtent,
);
}
}
class UnboundedRenderViewport = RenderViewport
with UnboundedRenderViewportMixin;
mixin UnboundedRenderViewportMixin on RenderViewport {
bool get sizedByParent => false;
double _unboundedSize = double.infinity;
void performLayout() {
BoxConstraints constraints = this.constraints;
if (axis == Axis.horizontal) {
_unboundedSize = constraints.maxHeight;
size = Size(constraints.maxWidth, 0);
} else {
_unboundedSize = constraints.maxWidth;
size = Size(0, constraints.maxHeight);
}
super.performLayout();
switch (axis) {
case Axis.vertical:
offset.applyViewportDimension(size.height);
break;
case Axis.horizontal:
offset.applyViewportDimension(size.width);
break;
}
}
double layoutChildSequence({
required RenderSliver? child,
required double scrollOffset,
required double overlap,
required double layoutOffset,
required double remainingPaintExtent,
required double mainAxisExtent,
required double crossAxisExtent,
required GrowthDirection growthDirection,
required RenderSliver? advance(RenderSliver child),
required double remainingCacheExtent,
required double cacheOrigin,
}) {
crossAxisExtent = _unboundedSize;
var firstChild = child;
final result = super.layoutChildSequence(
child: child,
scrollOffset: scrollOffset,
overlap: overlap,
layoutOffset: layoutOffset,
remainingPaintExtent: remainingPaintExtent,
mainAxisExtent: mainAxisExtent,
crossAxisExtent: crossAxisExtent,
growthDirection: growthDirection,
advance: advance,
remainingCacheExtent: remainingCacheExtent,
cacheOrigin: cacheOrigin,
);
double unboundedSize = 0;
while (firstChild != null) {
if (firstChild.geometry is UnboundedSliverGeometry) {
final UnboundedSliverGeometry childGeometry =
firstChild.geometry as UnboundedSliverGeometry;
unboundedSize = math.max(unboundedSize, childGeometry.crossAxisSize);
}
firstChild = advance(firstChild);
}
if (axis == Axis.horizontal) {
size = Size(size.width, unboundedSize);
} else {
size = Size(unboundedSize, size.height);
}
return result;
}
}
接下来我们继承 SliverGeometry
自定义一个 UnboundedSliverGeometry
,主要就是增加了一个 crossAxisSize
参数,用来记录当前统计到的副轴高度,从而让上面的 ViewPort
可以获取得到。
class UnboundedSliverGeometry extends SliverGeometry {
UnboundedSliverGeometry(
{SliverGeometry? existing, required this.crossAxisSize})
: super(
scrollExtent: existing?.scrollExtent ?? 0.0,
paintExtent: existing?.paintExtent ?? 0.0,
paintOrigin: existing?.paintOrigin ?? 0.0,
layoutExtent: existing?.layoutExtent,
maxPaintExtent: existing?.maxPaintExtent ?? 0.0,
maxScrollObstructionExtent:
existing?.maxScrollObstructionExtent ?? 0.0,
hitTestExtent: existing?.hitTestExtent,
visible: existing?.visible,
hasVisualOverflow: existing?.hasVisualOverflow ?? false,
scrollOffsetCorrection: existing?.scrollOffsetCorrection,
cacheExtent: existing?.cacheExtent,
);
final double crossAxisSize;
}
如下代码所示,最终我们基于 SliverList
实现一个 UnboundedSliverList
,这也是核心逻辑,主要是实现 performLayout
部分的代码,我们需要在原来代码的基础上,在某些节点加上自定义的逻辑,用于统计参与布局的每个 Item 的高度,从而得到一个最大值。
代码看起来很长,但是其实我们新增的很少。
class UnboundedSliverList = SliverList with UnboundedSliverListMixin;
mixin UnboundedSliverListMixin on SliverList {
RenderSliverList createRenderObject(BuildContext context) {
final SliverMultiBoxAdaptorElement element =
context as SliverMultiBoxAdaptorElement;
return UnboundedRenderSliverList(childManager: element);
}
}
class UnboundedRenderSliverList extends RenderSliverList {
UnboundedRenderSliverList({
required RenderSliverBoxChildManager childManager,
}) : super(childManager: childManager);
// See RenderSliverList::performLayout
void performLayout() {
final SliverConstraints constraints = this.constraints;
childManager.didStartLayout();
childManager.setDidUnderflow(false);
final double scrollOffset =
constraints.scrollOffset + constraints.cacheOrigin;
assert(scrollOffset >= 0.0);
final double remainingExtent = constraints.remainingCacheExtent;
assert(remainingExtent >= 0.0);
final double targetEndScrollOffset = scrollOffset + remainingExtent;
BoxConstraints childConstraints = constraints.asBoxConstraints();
int leadingGarbage = 0;
int trailingGarbage = 0;
bool reachedEnd = false;
if (constraints.axis == Axis.horizontal) {
childConstraints = childConstraints.copyWith(minHeight: 0);
} else {
childConstraints = childConstraints.copyWith(minWidth: 0);
}
double unboundedSize = 0;
// should call update after each child is laid out
updateUnboundedSize(RenderBox? child) {
if (child == null) {
return;
}
unboundedSize = math.max(
unboundedSize,
constraints.axis == Axis.horizontal
? child.size.height
: child.size.width);
}
unboundedGeometry(SliverGeometry geometry) {
return UnboundedSliverGeometry(
existing: geometry,
crossAxisSize: unboundedSize,
);
}
// This algorithm in principle is straight-forward: find the first child
// that overlaps the given scrollOffset, creating more children at the top
// of the list if necessary, then walk down the list updating and laying out
// each child and adding more at the end if necessary until we have enough
// children to cover the entire viewport.
//
// It is complicated by one minor issue, which is that any time you update
// or create a child, it's possible that the some of the children that
// haven't yet been laid out will be removed, leaving the list in an
// inconsistent state, and requiring that missing nodes be recreated.
//
// To keep this mess tractable, this algorithm starts from what is currently
// the first child, if any, and then walks up and/or down from there, so
// that the nodes that might get removed are always at the edges of what has
// already been laid out.
// Make sure we have at least one child to start from.
if (firstChild == null) {
if (!addInitialChild()) {
// There are no children.
geometry = unboundedGeometry(SliverGeometry.zero);
childManager.didFinishLayout();
return;
}
}
// We have at least one child.
// These variables track the range of children that we have laid out. Within
// this range, the children have consecutive indices. Outside this range,
// it's possible for a child to get removed without notice.
RenderBox? leadingChildWithLayout, trailingChildWithLayout;
RenderBox? earliestUsefulChild = firstChild;
// A firstChild with null layout offset is likely a result of children
// reordering.
//
// We rely on firstChild to have accurate layout offset. In the case of null
// layout offset, we have to find the first child that has valid layout
// offset.
if (childScrollOffset(firstChild!) == null) {
int leadingChildrenWithoutLayoutOffset = 0;
while (earliestUsefulChild != null &&
childScrollOffset(earliestUsefulChild) == null) {
earliestUsefulChild = childAfter(earliestUsefulChild);
leadingChildrenWithoutLayoutOffset += 1;
}
// We should be able to destroy children with null layout offset safely,
// because they are likely outside of viewport
collectGarbage(leadingChildrenWithoutLayoutOffset, 0);
// If can not find a valid layout offset, start from the initial child.
if (firstChild == null) {
if (!addInitialChild()) {
// There are no children.
geometry = unboundedGeometry(SliverGeometry.zero);
childManager.didFinishLayout();
return;
}
}
}
// Find the last child that is at or before the scrollOffset.
earliestUsefulChild = firstChild;
for (double earliestScrollOffset = childScrollOffset(earliestUsefulChild!)!;
earliestScrollOffset > scrollOffset;
earliestScrollOffset = childScrollOffset(earliestUsefulChild)!) {
// We have to add children before the earliestUsefulChild.
earliestUsefulChild =
insertAndLayoutLeadingChild(childConstraints, parentUsesSize: true);
updateUnboundedSize(earliestUsefulChild);
if (earliestUsefulChild == null) {
final SliverMultiBoxAdaptorParentData childParentData =
firstChild!.parentData! as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = 0.0;
if (scrollOffset == 0.0) {
// insertAndLayoutLeadingChild only lays out the children before
// firstChild. In this case, nothing has been laid out. We have
// to lay out firstChild manually.
firstChild!.layout(childConstraints, parentUsesSize: true);
earliestUsefulChild = firstChild;
updateUnboundedSize(earliestUsefulChild);
leadingChildWithLayout = earliestUsefulChild;
trailingChildWithLayout ??= earliestUsefulChild;
break;
} else {
// We ran out of children before reaching the scroll offset.
// We must inform our parent that this sliver cannot fulfill
// its contract and that we need a scroll offset correction.
geometry = unboundedGeometry(SliverGeometry(
scrollOffsetCorrection: -scrollOffset,
));
return;
}
}
final double firstChildScrollOffset =
earliestScrollOffset - paintExtentOf(firstChild!);
// firstChildScrollOffset may contain double precision error
if (firstChildScrollOffset < -precisionErrorTolerance) {
// Let's assume there is no child before the first child. We will
// correct it on the next layout if it is not.
geometry = unboundedGeometry(SliverGeometry(
scrollOffsetCorrection: -firstChildScrollOffset,
));
final SliverMultiBoxAdaptorParentData childParentData =
firstChild!.parentData! as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = 0.0;
return;
}
final SliverMultiBoxAdaptorParentData childParentData =
earliestUsefulChild.parentData! as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = firstChildScrollOffset;
assert(earliestUsefulChild == firstChild);
leadingChildWithLayout = earliestUsefulChild;
trailingChildWithLayout ??= earliestUsefulChild;
}
assert(childScrollOffset(firstChild!)! > -precisionErrorTolerance);
// If the scroll offset is at zero, we should make sure we are
// actually at the beginning of the list.
if (scrollOffset < precisionErrorTolerance) {
// We iterate from the firstChild in case the leading child has a 0 paint
// extent.
while (indexOf(firstChild!) > 0) {
final double earliestScrollOffset = childScrollOffset(firstChild!)!;
// We correct one child at a time. If there are more children before
// the earliestUsefulChild, we will correct it once the scroll offset
// reaches zero again.
earliestUsefulChild =
insertAndLayoutLeadingChild(childConstraints, parentUsesSize: true);
updateUnboundedSize(earliestUsefulChild);
assert(earliestUsefulChild != null);
final double firstChildScrollOffset =
earliestScrollOffset - paintExtentOf(firstChild!);
final SliverMultiBoxAdaptorParentData childParentData =
firstChild!.parentData! as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = 0.0;
// We only need to correct if the leading child actually has a
// paint extent.
if (firstChildScrollOffset < -precisionErrorTolerance) {
geometry = unboundedGeometry(SliverGeometry(
scrollOffsetCorrection: -firstChildScrollOffset,
));
return;
}
}
}
// At this point, earliestUsefulChild is the first child, and is a child
// whose scrollOffset is at or before the scrollOffset, and
// leadingChildWithLayout and trailingChildWithLayout are either null or
// cover a range of render boxes that we have laid out with the first being
// the same as earliestUsefulChild and the last being either at or after the
// scroll offset.
assert(earliestUsefulChild == firstChild);
assert(childScrollOffset(earliestUsefulChild!)! <= scrollOffset);
// Make sure we've laid out at least one child.
if (leadingChildWithLayout == null) {
earliestUsefulChild!.layout(childConstraints, parentUsesSize: true);
updateUnboundedSize(earliestUsefulChild);
leadingChildWithLayout = earliestUsefulChild;
trailingChildWithLayout = earliestUsefulChild;
}
// Here, earliestUsefulChild is still the first child, it's got a
// scrollOffset that is at or before our actual scrollOffset, and it has
// been laid out, and is in fact our leadingChildWithLayout. It's possible
// that some children beyond that one have also been laid out.
bool inLayoutRange = true;
RenderBox? child = earliestUsefulChild;
int index = indexOf(child!);
double endScrollOffset = childScrollOffset(child)! + paintExtentOf(child);
bool advance() {
// returns true if we advanced, false if we have no more children
// This function is used in two different places below, to avoid code duplication.
assert(child != null);
if (child == trailingChildWithLayout) inLayoutRange = false;
child = childAfter(child!);
if (child == null) inLayoutRange = false;
index += 1;
if (!inLayoutRange) {
if (child == null || indexOf(child!) != index) {
// We are missing a child. Insert it (and lay it out) if possible.
child = insertAndLayoutChild(
childConstraints,
after: trailingChildWithLayout,
parentUsesSize: true,
);
updateUnboundedSize(child);
if (child == null) {
// We have run out of children.
return false;
}
} else {
// Lay out the child.
child!.layout(childConstraints, parentUsesSize: true);
updateUnboundedSize(child!);
}
trailingChildWithLayout = child;
}
assert(child != null);
final SliverMultiBoxAdaptorParentData childParentData =
child!.parentData! as SliverMultiBoxAdaptorParentData;
childParentData.layoutOffset = endScrollOffset;
assert(childParentData.index == index);
endScrollOffset = childScrollOffset(child!)! + paintExtentOf(child!);
return true;
}
// Find the first child that ends after the scroll offset.
while (endScrollOffset < scrollOffset) {
leadingGarbage += 1;
if (!advance()) {
assert(leadingGarbage == childCount);
assert(child == null);
// we want to make sure we keep the last child around so we know the end scroll offset
collectGarbage(leadingGarbage - 1, 0);
assert(firstChild == lastChild);
final double extent =
childScrollOffset(lastChild!)! + paintExtentOf(lastChild!);
geometry = unboundedGeometry(
SliverGeometry(
scrollExtent: extent,
paintExtent: 0.0,
maxPaintExtent: extent,
),
);
return;
}
}
// Now find the first child that ends after our end.
while (endScrollOffset < targetEndScrollOffset) {
if (!advance()) {
reachedEnd = true;
break;
}
}
// Finally count up all the remaining children and label them as garbage.
if (child != null) {
child = childAfter(child!);
while (child != null) {
trailingGarbage += 1;
child = childAfter(child!);
}
}
// At this point everything should be good to go, we just have to clean up
// the garbage and report the geometry.
collectGarbage(leadingGarbage, trailingGarbage);
assert(debugAssertChildListIsNonEmptyAndContiguous());
double estimatedMaxScrollOffset;
if (reachedEnd) {
estimatedMaxScrollOffset = endScrollOffset;
} else {
estimatedMaxScrollOffset = childManager.estimateMaxScrollOffset(
constraints,
firstIndex: indexOf(firstChild!),
lastIndex: indexOf(lastChild!),
leadingScrollOffset: childScrollOffset(firstChild!),
trailingScrollOffset: endScrollOffset,
);
assert(estimatedMaxScrollOffset >=
endScrollOffset - childScrollOffset(firstChild!)!);
}
final double paintExtent = calculatePaintOffset(
constraints,
from: childScrollOffset(firstChild!)!,
to: endScrollOffset,
);
final double cacheExtent = calculateCacheOffset(
constraints,
from: childScrollOffset(firstChild!)!,
to: endScrollOffset,
);
final double targetEndScrollOffsetForPaint =
constraints.scrollOffset + constraints.remainingPaintExtent;
geometry = unboundedGeometry(
SliverGeometry(
scrollExtent: estimatedMaxScrollOffset,
paintExtent: paintExtent,
cacheExtent: cacheExtent,
maxPaintExtent: estimatedMaxScrollOffset,
// Conservative to avoid flickering away the clip during scroll.
hasVisualOverflow: endScrollOffset > targetEndScrollOffsetForPaint ||
constraints.scrollOffset > 0.0,
),
);
// We may have started the layout while scrolled to the end, which would not
// expose a new child.
if (estimatedMaxScrollOffset == endScrollOffset)
childManager.setDidUnderflow(true);
childManager.didFinishLayout();
}
}
别看上面这段代码很长,其实很多都是 RenderSliverList
自己的源码,如下图所示,真正我们修改添加的只有这么点:
- 在开始前增加
updateUnboundedSize
和unboundedGeometry
用于记录布局高度和生成UnboundedSliverGeometry
- 将所有原来的
SliverGeometry
修改为UnboundedSliverGeometry
- 在所有涉及
layout
的位置后面调用updateUnboundedSize
,因为 child 在布局之后我们就可以获取到它的Size
,然后我们统计得到他们的最大值,就可以通过UnboundedSliverGeometry
返回给ViewPort
。
最后如下代码所示,将 UnboundedListView
添加到一开始的垂直 ListView
里,运行之后可以看到,随着横向滑动,列表的自身高度在发生变化。
return Scaffold(
appBar: AppBar(
title: new Text("ControllerDemoPage"),
),
extendBody: true,
body: Container(
color: Colors.white,
child: ListView(
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: IntrinsicHeight(
child: Row(
children: List<Widget>.generate(50, (index) {
return Padding(
padding: EdgeInsets.all(2),
child: Container(
alignment: Alignment.bottomCenter,
color: Colors.blue,
child: Text(List.generate(
math.Random().nextInt(10), (index) => "TEST\n")
.toString()),
),
);
}),
),
),
),
UnboundedListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 100,
itemBuilder: (context, index) {
print('$index');
return Padding(
padding: EdgeInsets.all(2),
child: Container(
height: index * 1.0 + 10,
width: 50,
color: Colors.blue,
),
);
}),
Container(
height: 1000,
color: Colors.green,
),
],
),
),
);
那么这是否达到了我们的需求?如下代码所示,假如我将代码修改成如下所示,运行之后可以看到,此时的横向列表变成了参差不齐的状态。
UnboundedListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 100,
itemBuilder: (context, index) {
print('$index');
return Container(
padding: EdgeInsets.all(2),
child: Container(
width: 50,
color: Colors.blue,
alignment: Alignment.bottomCenter,
child: Text(List.generate(
math.Random().nextInt(15), (index) => "TEST\n")
.toString()),
),
);
}),
但是这时候我们无法用类似 IntrinsicHeight
的方式来解决,因为 ListView
里的 Item 都是动态处理的,也就是布局时需要处理特定便宜范围内的 Item 添加和销毁,具体在 performLayout
里会通过 scrollOffset
和 targetEndScrollOffset
等来确定布局 Item 的范围。
这样就导致我们通过
firstChild
链表结构去访问的时候,我们无法在layout
之前获取到 child ,因为此时它还没有被 add 到链表里,同时也受限于insertAndLayoutLeadingChild
和insertAndLayoutChild
的耦合实现和私有方法限制,这里不方便简单重写支持。
但是「天无绝人之路」,既然我们不能在 child layout
之前处理,那么我们可以在 layout
之后做多一次冗余布局,如下代码所示:
- 我们首先将
unboundedSize
提取为UnboundedRenderSliverList
里的全局变量 - 在
didFinishLayout
之前,通过firstChild
链表结构,重新通过layout(childConstraints.tighten(height: unboundedSize)
布局多一次
double unboundedSize = 0;
// See RenderSliverList::performLayout
void performLayout() {
····
var tmpChild = firstChild;
while (tmpChild != null) {
tmpChild.layout(childConstraints.tighten(height: unboundedSize),
parentUsesSize: true);
tmpChild = childAfter(tmpChild);
}
childManager.didFinishLayout();
····
}
运行之后可以看到,此时列表已经全部对齐,而损耗就是 child 会有 double 布局的情况,对于此处性能损耗,对比 SingleChildScrollView
的实现,可以根据实际场景来取舍使用哪种逻辑,当然,为了性能考虑非必要还是给横向 ListView
一个高度,这样的实现才是最优解。
好了,本篇小技巧到这里就解决了,不知道对于类似实现,你是否还有什么想法,如果你有更好的解决方案,欢迎留言讨论。
完整代码可见:https://github.com/CarGuo/gsy_flutter_demo/blob/master/lib/widget/un_bounded_listview.dart