一、引言
在企业 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 | class HorizontalListInVerticalList extends StatelessWidget { |
逐层解释:
- Stack 的高度:由非
Positioned子元素中尺寸最大的那个决定。这里的IgnorePointer + Opacity(opacity: 0)包裹的EmojiItem就是一个普通子元素,它的高度就是 Stack 最终的高度。 - Stack 的宽度:
SizedBox(width: double.infinity)将宽度撑开到父级约束允许的最大值(即屏幕宽度),确保横向列表有足够的滚动空间。 - Positioned.fill:让横向
ListView填满整个 Stack,与占位元素等高等宽,表达式正常展开。 - IgnorePointer + Opacity(opacity: 0):占位元素既不可见,也不响应点击事件,用户完全感知不到它的存在。
这个方案的精妙之处在于:用一个真实的 Item 来「测量」出正确的高度,而不是手动硬编码高度值。 即使后续调整了表情尺寸、padding 等样式,占位 Item 会自动同步变化,无需修改布局代码。
2.3 业务实践:聊天页面的表情面板
在我们的 IM 聊天页面中,表情面板分为「最近使用」「全部表情」「GIF 表情」三页,每页都是横向 ListView,外层用 PageView 做分页切换,再外层是竖向消息列表的一组子元素。
实际代码中,我们将表情面板整体封装为 EmojiPanel Widget,内部处理好了高度测量逻辑。聊天页面只需:
1 | ListView( |
组件化的好处是「测量高度的脏活」只写一次,所有使用方无感。
三、场景二:竖向 ListView 嵌套竖向 ListView
典型需求:通讯录页面,外层是部门分组列表,每个分组展开后内部是成员列表。部门数量和每个部门的成员数量都不固定。
3.1 shrinkWrap 方案及其代价
最常见的写法是给内层 ListView 加 shrinkWrap: true + physics: NeverScrollableScrollPhysics():
1 | ListView.builder( |
这段代码能跑,但有一个致命的性能问题:shrinkWrap: true 会让 ListView 一次性构建所有子元素,失去按需加载的能力。
正常 ListView 的核心优势是「虚拟化」——只渲染可视区域内的 Item。而 shrinkWrap 要求先知道所有子元素的总高度才能确定自身尺寸,所以必须全部构建一遍。
在通讯录场景中,如果某部门有 500 名成员,shrinkWrap 会在展开瞬间构建 500 个 ContactCell,造成明显的卡顿。如果多个大部门同时展开,内存压力会急剧上升。
3.2 共享 ScrollController:真正的滑动一体化
更好的思路是:不要让外层和内层各滑各的,而是把它们合并成一个整体的滚动视图。
1 | final ScrollController _scrollController = ScrollController(); |
核心做法是将一个嵌套结构「拍平」成一维列表。部门 Header 和成员 Cell 都是同一个 ListView 的子项,通过数据结构区分渲染类型。
在企业 IM 的通讯录中,我们有 18 万+组织架构成员,任何 shrinkWrap 方案都是不可接受的。实际采用的方案就是拍平 + 虚拟化,配合分页加载(pagination)和首字母索引快速跳转。
拍平后的数据结构如下:
1 | abstract class ContactListItem {} |
ListView.builder 根据 item 的实际类型决定渲染 DepartmentItem 还是 MemberItem,滑动体验与单一列表完全一致。
3.3 另一种思路:Sliver 方案
如果你确实需要保留「不同列表有独立滚动行为」的语义(比如消息列表 + 群公告 + 群文件的三段式布局),可以考虑 CustomScrollView + SliverList:
1 | CustomScrollView( |
Sliver 方案下,所有内容在同一个滚动坐标系内,不需要 shrinkWrap,也不需要共享 ScrollController。每段 SliverList 都有自己的 childCount,按需构建,兼顾了语义清晰和性能优秀。
四、总结
| 场景 | 推荐方案 | 关键点 |
|---|---|---|
| 竖向套横向 | Stack + 隐形占位测量高度 | 用真实 Item 测高,避免硬编码 |
| 竖向套竖向(子项少) | shrinkWrap + NeverScrollableScrollPhysics |
子项少于 50 时可接受 |
| 竖向套竖向(子项多) | 拍平数据结构,合并为单层 ListView | 核心性能优化,按需加载 |
| 多段独立列表 | CustomScrollView + SliverList |
共享滚动坐标系,语义清晰 |
嵌套 ListView 的本质问题不是「能不能嵌套」,而是如何避免让子列表破坏父列表的虚拟化能力。无论是 Stack 测高、拍平数据还是 Sliver 方案,核心目标都是让列表始终保持按需构建、按需回收的渲染模式。
扫描二维码,分享此文章