注册

比 Flutter ListView 更灵活的布局方式

大家好,我是 17。


在 Flutter 中,涉及到滚动布局的时候,很多同学会大量使用 ListView


ListView 的局限


没错,在实现效果的方面 ListView 确实能做到大多数,但是有些情况下会很别扭,性能也不好。你可能遇到过下面的设计:


6a7f64bf90f2428b94e77f32a5d9d4f0~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp?

banner 和下面的列表是一起滚动的。如果用 ListView ,你一定可以马上写出代码:


ListView.builder(
itemBuilder: (context, index) {
if (index == 0) {
return Container(
height: 100,
color: Colors.blue,
child: Text('banner'),
);
} else {
return ListTile(title: Text('${index - 1}'));
}
},
itemCount: 100
)

上面的代码会有一个问题,banner 的高度和下面列表的高度不一样,导致无法使用定高列表,造成性能下降。需要 if else,如果有多个 banner,if else 也要多个,那就相当复杂了。


还有一个问题,在你没有设置任何边距的情况下,ListView 和上面的 Widget 可能会一段空白。


cb4ef6b03fa44c55a33d5da6e595ead2~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp?

你需要这样去除空白。


ListView.builder(
padding: EdgeInsets.zero,

为什么会有空白呢?这是因为 ListView 继承自 BoxScrollView,它的主要贡献就是加了这个空白!


这个空白的值是多少呢?就是取的 mediaQuery 的 padding。因为浏海屏的出现,ios 中,上面和下面会有一部分不适合显示主要内容,所以就有了这个安全 padding。BoxScrollView 在设计的时候也考虑到了这一点,于是就默认加了这个 padding。但实际上,如果 listView 不是在最顶部,反而是帮了倒忙。


ListView 最理想的使用场景是展示的 item 都一样高,但多数情况下,item 是不一样高的。ListView 出现的目的是为了方便使用,但却是牺牲了灵活性。它只能有一个 SliverChild,这会导致 itemBuilder 函数逻辑的复杂和性能的下降。


更灵活的布局方式


其实我们可以直接从 ScrollView 继承,根据实际情况定制需要的组件。说到定制你可能会觉得一定很复杂,实际上是非常简单的,而且因为我们是根据业务量身定做的组件,所以用起来会特别顺手。


要用 ScrollView 实现上面的设计,只需要下面的代码:


class MyListView extends ScrollView {
const MyListView(
{Key? key,
this.banner,
required this.itemBuilder,
required this.itemExtent,
required this.itemCount})
: super(key: key);
final Widget? banner;
final IndexedWidgetBuilder itemBuilder;
final double itemExtent;
final int itemCount;

@override
List<Widget> buildSlivers(BuildContext context) {
List<Widget> list = [];
if (banner != null) {
list.add(SliverToBoxAdapter(child: banner!));
}
list.add(SliverFixedExtentList(
delegate: SliverChildBuilderDelegate(itemBuilder, childCount: itemCount),
itemExtent: itemExtent,
));
return list;
}
}

很简单吧。实际上,我们只是 override buildSlivers 方法,生成一个 list。SliverToBoxAdapter 可以看作是一个转换器,把普通的 Widget 转换为 Sliver Widget。虽然 buildSlivers 的返回值是 List<Widget> ,但实际上,Widget 应该是 Sliver Widget,否则无法滚动。


MyListView 使用起来也很方便,代码更简洁,没有了讨厌的 if else 了。


MyListView(
banner: Container(color: Colors.green, height: 100),
itemExtent: 20,
itemCount: 100,
itemBuilder: (context, index) => Text('$index'),
)

现在 banner 和 item 的逻辑是分开的,代码更加清晰,也更好维护。把 banner 这个高度不一样的 widget 分开后,剩下的 item 高度都是一样的,本例中,我们设置固定高度 itemExtent: 20,每个 item 的高度都是 20,在 buildSlivers 中用 itemExtent 做为参数,用 SliverFixedExtentList 生成定高列表,性能得到大大提高。


老板说,把第 10 条数据显示在第一的位置


这个需求还是很常见的,在某个时刻,需要把某条数据显示在第一的位置。如果用 ListView 实现起来不容易,你可能想要调整数据的位置,但需求是数据的位置不变,只是想让 ViewPort 滚动到 第 10 条数据的位置。你可能还想到了用 ListView 的 controller 来控制滚动位置,尝试一下可以知道并不方便实现,或者实现了也不方便维护。


直接用 ScrollView 就很简单了。 ScrollView 有一个参数可以直接实现在这样的功能,这个参数就是 center。你可能很奇怪,ListView 是从 BoxScrollView 继承,BoxScrollView 是从 ScrollView 继承,但是在 ListView 中没有发现这个参数啊?为了方便使用,BoxScrollView 只有一个 Sliver Child,center 参数没有了用武之地,在 ListView 中找不到这个参数也就不奇怪了。


实现功能


先看下效果,不使用 center 参数,banner 在第一个位置显示。


d8f8a6d76f2e4d66af548cc105ea2608~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp?

使用 center 参数后,第 10 条数据,自动显示在第一个位置。


ad1d6afdfb034bde8eab8575a2245a34~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp?

下面是完整代码,贴到 main.dart 就能运行


import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
const MyApp({super.key});

@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(body: MyWidget()),
);
}
}

class MyWidget extends StatefulWidget {
const MyWidget({super.key});

@override
State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
@override
Widget build(BuildContext context) {
return SafeArea(
child: MyListView(
banner: Container(
color: Colors.blue[100],
alignment: Alignment.center,
height: 100,
child: const Text(
'IAM17 Flutter 天天更新',
),
),
itemBuilder: (context, index) {
return ListTile(
title: Text('$index'),
);
},
center: const ValueKey(9),
itemExtent: 20,
itemCount: 100));
}
}

class MyListView extends ScrollView {
const MyListView(
{Key? key,
this.banner,
required this.itemBuilder,
required this.itemExtent,
required this.itemCount,
Key? center})
: super(key: key, center: center);
final Widget? banner;
final IndexedWidgetBuilder itemBuilder;
final double itemExtent;
final int itemCount;

@override
List<Widget> buildSlivers(BuildContext context) {
List<Widget> list = [];
if (banner != null) {
list.add(SliverToBoxAdapter(child: banner!));
}
if (center == null) {
list.add(SliverFixedExtentList(
delegate:
SliverChildBuilderDelegate(itemBuilder, childCount: itemCount),
itemExtent: itemExtent,
));
} else {
for (var i = 0; i < itemCount; i++) {
list.add(SliverToBoxAdapter(
key: ValueKey(i),
child: itemBuilder(context, i),
));
}
}
return list;
}
}

当 center 不为 null 的时候,放弃使用 SliverFixedExtentList,只能把 child 一个一个加到 list 中。这样会损失一些性能,但能快速实现需求,还是值得的。


center 参数是如何影响位置的?


在 ViewPort 的构造函数中有一个 assert,如果 center 不为空,那么在 slivers 中必须要找到 key 为 center 的 child。


Viewport({
...
this.center,
...
}) :
assert(center == null || slivers.where((Widget child) => child.key == center).length == 1),

最终是给 ViewPort 对应的 renderObject 的 center 赋值。


代码位置 : flutter/lib/src/widgets/viewport.dart


void _updateCenter() {
// TODO(ianh): cache the keys to make this faster
final Viewport viewport = widget as Viewport;
if (viewport.center != null) {
int elementIndex = 0;
for (final Element e in children) {
if (e.widget.key == viewport.center) {
renderObject.center = e.renderObject as RenderSliver?;
break;
}
elementIndex++;
}
assert(elementIndex < children.length);
_centerSlotIndex = elementIndex;
} else if (children.isNotEmpty) {
renderObject.center = children.first.renderObject as RenderSliver?;
_centerSlotIndex = 0;
} else {
renderObject.center = null;
_centerSlotIndex = null;
}
}

总之,就是通过 key 找到对应的 Sliver Widget,对应到 renderObject,实现 center 的功能。


通过这个简单的案例说明,我们应该自己动手定制适合自己项目的 ”ListView“!通过简单的封装,就能让我们的代码更简洁,更容易维护,性能也会更好。


更多关于滚动的参数介绍可以看这篇 flutter 滚动的基石 Scrollable


回答下 @法的空间 提的问题:CustomScrollView 的意义何在?


BoxScrollView 和 CustomScrollView 都是 ScrollView 的 子类。BoxScrollView 只能创建一块滑动内容,CustomScrollView 可以支持滑动列表,这就是 CustomScrollView 的意义。


之所以没有直接用 CustomScrollView ,而是直接从 ScrollView 继承是为了可以把一些属性和滑动列表一起封装起来,方便使用。


如果代码不需要复用,直接用 CustomScrollView 也是可以的,而且也是最简单的方式。


CustomScrollView 的代码就一句:


 @override
List<Widget> buildSlivers(BuildContext context) => slivers;

ScrollView 是抽象类,不能直接用,CustomScrollView 的意义在于:我们不需要每次都要 extends 一个类出来,用 CustomScrollView 就可以支持滑动列表。


希望已经解答了你的问题,谢谢提问!


作者:IAM17
链接:https://juejin.cn/post/7184955986224873531
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册