cubegao

如何在ListView中优雅的嵌套ListView

2021-07-06

一、引言

在企业 IM 应用中,嵌套列表是高频需求。聊天页面的横向表情面板、通讯录中分组 + 成员列表、工作台中分类 + 应用列表……这些场景都涉及「列表套列表」。如果处理不当,轻则滑动卡顿,重则内存溢出。

本文将梳理两种嵌套场景的核心问题和最佳实践,结合 IM 项目中的真实经验给出可落地的方案。

二、场景一:竖向 ListView 嵌套横向 ListView

典型需求:聊天页面输入框上方有一个横向滑动的表情面板,面板内表情需要分页横向滚动,而整个聊天页面本身是竖向滚动的消息列表。

核心矛盾在于:横向 ListView 需要知道自己有多高才能确定自身布局,但它的高度又取决于内部元素的实际渲染尺寸。

2.1 直接嵌套的问题

如果把横向 ListView 直接放在竖向 ListView 中,不指定高度,会触发无限高度错误:

1
RenderFlex children have non-zero flex but incoming height constraints are unbounded.

因为竖向 ListView 给子元素的是「无界高度约束」,而横向 ListView 需要确定的高度才能计算自身布局。

2.2 用 Stack + 隐形占位元素确定高度

解决方案的核心思路是:用一个不可见的占位 Item 提前计算出横向列表的准确高度,再用 Stack 让真正的横向列表覆盖在上面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class HorizontalListInVerticalList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView(
children: [
// 其他竖向列表内容...
Text('表情'),
Stack(
children: [
// 占位层:计算高度,但不显示
IgnorePointer(
child: Opacity(
opacity: 0.0,
child: EmojiItem(), // 单个 Item,决定 Stack 的高度
),
),
// 宽度撑开层:让 Stack 宽度撑满屏幕
SizedBox(width: double.infinity),
// 真实内容层:横向 ListView
Positioned.fill(
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemBuilder: (_, index) => EmojiItem(),
),
),
],
),
],
);
}
}

逐层解释

  1. Stack 的高度:由非 Positioned 子元素中尺寸最大的那个决定。这里的 IgnorePointer + Opacity(opacity: 0) 包裹的 EmojiItem 就是一个普通子元素,它的高度就是 Stack 最终的高度。
  2. Stack 的宽度SizedBox(width: double.infinity) 将宽度撑开到父级约束允许的最大值(即屏幕宽度),确保横向列表有足够的滚动空间。
  3. Positioned.fill:让横向 ListView 填满整个 Stack,与占位元素等高等宽,表达式正常展开。
  4. IgnorePointer + Opacity(opacity: 0):占位元素既不可见,也不响应点击事件,用户完全感知不到它的存在。

这个方案的精妙之处在于:用一个真实的 Item 来「测量」出正确的高度,而不是手动硬编码高度值。 即使后续调整了表情尺寸、padding 等样式,占位 Item 会自动同步变化,无需修改布局代码。

2.3 业务实践:聊天页面的表情面板

在我们的 IM 聊天页面中,表情面板分为「最近使用」「全部表情」「GIF 表情」三页,每页都是横向 ListView,外层用 PageView 做分页切换,再外层是竖向消息列表的一组子元素。

实际代码中,我们将表情面板整体封装为 EmojiPanel Widget,内部处理好了高度测量逻辑。聊天页面只需:

1
2
3
4
5
6
7
ListView(
children: [
MessageListWidget(messages),
EmojiPanel(), // 封装好的组件,自己解决高度问题
InputBar(),
],
)

组件化的好处是「测量高度的脏活」只写一次,所有使用方无感。

三、场景二:竖向 ListView 嵌套竖向 ListView

典型需求:通讯录页面,外层是部门分组列表,每个分组展开后内部是成员列表。部门数量和每个部门的成员数量都不固定。

3.1 shrinkWrap 方案及其代价

最常见的写法是给内层 ListViewshrinkWrap: true + physics: NeverScrollableScrollPhysics()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ListView.builder(
itemCount: departments.length,
itemBuilder: (_, deptIndex) {
return Column(
children: [
DepartmentHeader(departments[deptIndex]),
ListView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemCount: departments[deptIndex].members.length,
itemBuilder: (_, memberIndex) =>
ContactCell(departments[deptIndex].members[memberIndex]),
),
],
);
},
)

这段代码能跑,但有一个致命的性能问题:shrinkWrap: true 会让 ListView 一次性构建所有子元素,失去按需加载的能力。

正常 ListView 的核心优势是「虚拟化」——只渲染可视区域内的 Item。而 shrinkWrap 要求先知道所有子元素的总高度才能确定自身尺寸,所以必须全部构建一遍。

在通讯录场景中,如果某部门有 500 名成员,shrinkWrap 会在展开瞬间构建 500 个 ContactCell,造成明显的卡顿。如果多个大部门同时展开,内存压力会急剧上升。

3.2 共享 ScrollController:真正的滑动一体化

更好的思路是:不要让外层和内层各滑各的,而是把它们合并成一个整体的滚动视图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
final ScrollController _scrollController = ScrollController();

ListView.builder(
controller: _scrollController,
itemCount: _flatList.length,
itemBuilder: (context, index) {
final item = _flatList[index];
if (item is DepartmentHeader) {
return DepartmentHeaderWidget(item);
} else {
return ContactCell(item as Member);
}
},
)

核心做法是将一个嵌套结构「拍平」成一维列表。部门 Header 和成员 Cell 都是同一个 ListView 的子项,通过数据结构区分渲染类型。

在企业 IM 的通讯录中,我们有 18 万+组织架构成员,任何 shrinkWrap 方案都是不可接受的。实际采用的方案就是拍平 + 虚拟化,配合分页加载(pagination)和首字母索引快速跳转。

拍平后的数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
abstract class ContactListItem {}

class DepartmentItem extends ContactListItem {
final String name;
final int memberCount;
}

class MemberItem extends ContactListItem {
final String name;
final String avatarUrl;
final String department;
}

ListView.builder 根据 item 的实际类型决定渲染 DepartmentItem 还是 MemberItem,滑动体验与单一列表完全一致。

3.3 另一种思路:Sliver 方案

如果你确实需要保留「不同列表有独立滚动行为」的语义(比如消息列表 + 群公告 + 群文件的三段式布局),可以考虑 CustomScrollView + SliverList

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
CustomScrollView(
slivers: [
SliverList(
delegate: SliverChildListDelegate([
GroupNoticeBanner(),
]),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(_, index) => MessageBubble(messages[index]),
childCount: messages.length,
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(_, index) => FileItem(files[index]),
childCount: files.length,
),
),
],
)

Sliver 方案下,所有内容在同一个滚动坐标系内,不需要 shrinkWrap,也不需要共享 ScrollController。每段 SliverList 都有自己的 childCount,按需构建,兼顾了语义清晰和性能优秀。

四、总结

场景 推荐方案 关键点
竖向套横向 Stack + 隐形占位测量高度 用真实 Item 测高,避免硬编码
竖向套竖向(子项少) shrinkWrap + NeverScrollableScrollPhysics 子项少于 50 时可接受
竖向套竖向(子项多) 拍平数据结构,合并为单层 ListView 核心性能优化,按需加载
多段独立列表 CustomScrollView + SliverList 共享滚动坐标系,语义清晰

嵌套 ListView 的本质问题不是「能不能嵌套」,而是如何避免让子列表破坏父列表的虚拟化能力。无论是 Stack 测高、拍平数据还是 Sliver 方案,核心目标都是让列表始终保持按需构建、按需回收的渲染模式。

扫描二维码,分享此文章