1. 这个需求到底在解决什么问题
移动端做数据密集型界面时,表格和列表的横竖交叉滚动几乎是绕不开的坎。尤其是当列数超过一屏、行数也超过一屏的时候,用户既想横向看后面的字段,又想纵向翻更多记录,同时还希望表头和左侧关键列始终可见——这就是典型的“双向冻结”场景。Flutter 里实现这个效果,说难不难,说简单也容易踩坑,核心思路就一句话:把二维滚动拆成四个象限,用联动控制器把它们绑在一起。
我最早接触这类需求是在做一个生产排程看板的时候,左侧是工位名称,右侧是未来三十天的排产格子,横向滑到第十天的时候,左侧工位名如果跟着滑走,用户根本不知道自己看的是哪一行。后来陆续在库存盘点、财务对账、课程表这类场景里反复遇到同样的诉求,慢慢总结出一套比较稳的实现套路。这篇文章就把这套方案完整拆开,从布局设计到控制器联动,再到性能优化和常见问题排查,全部讲透。
适合谁看?如果你已经写过基础的 Flutter 列表,对ListView、SingleChildScrollView这些组件不陌生,但遇到“横竖都要滚、还要固定头和左列”的需求时不知道从哪下手,那这篇就是写给你的。如果你已经实现过但滑动不同步、卡顿、边界回弹有问题,也能在这里找到排查思路。
提示:本文所有代码基于 Flutter 稳定版通用 API,不依赖任何第三方表格库,纯原生组件组合实现,方便你按需裁剪。
2. 整体布局思路与方案选型
2.1 为什么不用现成的表格组件
Flutter 生态里确实有一些表格类的包,能直接支持冻结行列。但实际项目里我不太推荐一上来就引入,原因有三个。第一,这类包往往为了通用性做了大量抽象,包体积和渲染开销都不小,而我们的场景可能只需要一个固定头加固定左列,杀鸡用牛刀。第二,定制化受限,比如单元格里要放进度条、徽标、可点击的图标,包的 API 不一定支持得优雅。第三,出问题时排查成本高,你得先读懂别人的源码才能定位。
所以我的选择是:用最基础的滚动组件自己拼。核心就四个区域——左上角固定块、顶部固定行、左侧固定列、右下角主内容区。这四个区域用Row和Column嵌套起来,再用两个ScrollController分别控制横向和纵向的联动。
2.2 四象限布局的结构拆解
先画个结构图在脑子里。整个页面从上到下分两行:第一行是“左上角固定块 + 顶部固定行”,第二行是“左侧固定列 + 右下主内容”。第一行的高度固定,就是表头高度;第一列的宽度固定,就是左侧列宽。
┌──────────┬──────────────────────────┐ │ 左上固定 │ 顶部固定行(横向滚动) │ ├──────────┼──────────────────────────┤ │ 左侧固定 │ 右下主内容区 │ │ (纵向) │ (横向+纵向都滚动) │ │ │ │ └──────────┴──────────────────────────┘关键点在于:顶部固定行只跟随横向滚动,左侧固定列只跟随纵向滚动,右下主内容区横竖都跟随。左上角那块什么都不跟随,永远钉在那里。
2.3 控制器联动的核心机制
联动靠的是ScrollController的addListener。具体来说,右下主内容区挂两个控制器——一个横向的、一个纵向的。顶部固定行挂一个横向控制器,左侧固定列挂一个纵向控制器。当主内容区横向滚动时,监听横向控制器的offset,同步设置给顶部固定行的横向控制器;纵向滚动同理,同步给左侧固定列。
这里有个细节要注意:监听回调里设置另一个控制器的jumpTo时要判断hasClients,否则在组件还没挂载完成时调用会抛异常。另外,如果两个控制器的offset已经相等,就不要重复设置,避免无限循环触发。
注意:不要用
animateTo做联动,那样会有延迟感,滑动跟手感很差。联动必须用jumpTo,保证实时同步。
3. 核心细节解析与实操要点
3.1 横向与纵向控制器的职责划分
我把控制器分成两组来管理。第一组是横向组:_headerHController给顶部固定行用,_bodyHController给右下主内容区用。第二组是纵向组:_leftVController给左侧固定列用,_bodyVController给右下主内容区用。
主内容区的两个控制器是“源头”,固定行列的控制器是“跟随者”。所以监听逻辑写在主内容区的控制器上:
_bodyHController.addListener(() { if (_headerHController.hasClients && _headerHController.offset != _bodyHController.offset) { _headerHController.jumpTo(_bodyHController.offset); } }); _bodyVController.addListener(() { if (_leftVController.hasClients && _leftVController.offset != _bodyVController.offset) { _leftVController.jumpTo(_bodyVController.offset); } });这段代码是整个方案的心脏。hasClients判断保证控制器已经绑定了滚动视图,offset不等判断防止重复触发。
3.2 单元格宽度与高度必须严格对齐
联动做好了,但如果顶部固定行的单元格宽度和主内容区的单元格宽度对不上,横向滑动时列就会错位,看起来像“漂移”。解决办法是把列宽定义成统一的常量列表,顶部行和主内容区都从这个列表取值。
const List<double> columnWidths = [120, 100, 100, 100, 100, 100]; const double leftColumnWidth = 120; const double headerHeight = 48; const double rowHeight = 44;行高同理,左侧固定列和主内容区的每一行高度必须一致。我一般把行高也定义成常量,所有地方引用同一个值。这样即使后面要调整,改一处就行,不会出现某一边漏改导致错位。
3.3 滚动条的隐藏与边界处理
默认情况下,SingleChildScrollView在桌面端或 Web 端会显示滚动条,移动端不显示。如果你希望统一隐藏,可以用ScrollConfiguration包一层,把scrollbars设为false。但要注意,隐藏滚动条不代表不能滚动,只是视觉上不显示。
边界处理方面,横向滚动到底时,顶部固定行也必须同步到底。因为用的是jumpTo同步offset,只要两边内容总宽度一致,边界自然对齐。如果发现到底部时有一两像素的偏差,通常是两边padding不一致导致的,检查一下EdgeInsets是否统一。
提示:如果内容总宽度小于屏幕宽度,横向其实不需要滚动。这时候可以判断一下,如果不需要滚动就不挂控制器,避免无意义的联动开销。
3.4 数据量大的时候怎么保证流畅
右下主内容区如果一次性渲染几百行,即使有ListView.builder的懒加载,横向滚动时每一行都要重建,压力还是不小。我的做法是横向也用懒加载——主内容区的每一行内部,横向内容用ListView.builder横向滚动,而不是一次性把所有列都铺出来。
但这里有个矛盾:如果每行内部各自横向滚动,那横向联动就变成了行与行之间的同步,复杂度陡增。所以更实际的做法是:整个主内容区作为一个横向滚动视图,内部纵向用ListView.builder。这样横向只有一个滚动源,纵向懒加载行,性能比较均衡。
SingleChildScrollView( controller: _bodyHController, scrollDirection: Axis.horizontal, child: SizedBox( width: totalWidth, child: ListView.builder( controller: _bodyVController, itemCount: rows.length, itemBuilder: (context, index) => _buildRow(rows[index]), ), ), )这个结构下,横向滚动是整个区域一起动,纵向是行级别的懒加载。实测下来,几百行数据滑动依然跟手。
4. 完整实操过程与核心环节实现
4.1 定义常量与数据结构
先把基础配置定好。列宽列表、左侧列宽、表头高度、行高,这些是后面所有布局的基准。
class TableConfig { static const double leftWidth = 120; static const double headerHeight = 48; static const double rowHeight = 44; static const List<double> colWidths = [100, 100, 100, 100, 100]; static double get totalWidth => colWidths.fold(0, (sum, w) => sum + w); }数据行用一个简单的模型表示,左侧一个字段,右侧一个列表对应各列。
class RowData { final String label; final List<String> cells; RowData(this.label, this.cells); }4.2 搭建四象限骨架
整个页面用Column分上下两部分。上半部分是表头行,用Row放左上固定块和顶部固定行;下半部分是内容行,用Row放左侧固定列和右下主内容区。下半部分要用Expanded撑满剩余高度。
Column( children: [ // 表头行 SizedBox( height: TableConfig.headerHeight, child: Row( children: [ _buildCorner(), Expanded(child: _buildHeaderRow()), ], ), ), // 内容行 Expanded( child: Row( children: [ _buildLeftColumn(), Expanded(child: _buildBody()), ], ), ), ], )左上角固定块就是一个宽度等于左侧列宽、高度等于表头高度的容器,里面可以放个标题或者留空。
4.3 顶部固定行的实现
顶部固定行是一个横向的SingleChildScrollView,里面用Row铺开所有列头。注意宽度要和主内容区一致,所以每个列头的宽度从colWidths取。
Widget _buildHeaderRow() { return SingleChildScrollView( controller: _headerHController, scrollDirection: Axis.horizontal, physics: const NeverScrollableScrollPhysics(), child: Row( children: List.generate( TableConfig.colWidths.length, (i) => Container( width: TableConfig.colWidths[i], height: TableConfig.headerHeight, alignment: Alignment.center, child: Text('列${i + 1}'), ), ), ), ); }这里physics设为NeverScrollableScrollPhysics,因为顶部行不需要用户直接滑动,它只跟随主内容区联动。这样也避免了用户误触顶部行导致两边不同步。
4.4 左侧固定列的实现
左侧固定列是一个纵向的ListView,每一项高度等于行高,宽度等于左侧列宽。同样禁止直接滚动,只跟随联动。
Widget _buildLeftColumn() { return SizedBox( width: TableConfig.leftWidth, child: ListView.builder( controller: _leftVController, physics: const NeverScrollableScrollPhysics(), itemCount: rows.length, itemExtent: TableConfig.rowHeight, itemBuilder: (context, index) => Container( alignment: Alignment.centerLeft, padding: const EdgeInsets.only(left: 12), child: Text(rows[index].label), ), ), ); }用itemExtent指定固定行高,比在itemBuilder里设高度性能更好,因为ListView可以直接算出滚动范围,不用逐个测量。
4.5 右下主内容区的实现
主内容区是横向SingleChildScrollView套纵向ListView.builder。横向滚动由_bodyHController控制,纵向由_bodyVController控制。
Widget _buildBody() { return SingleChildScrollView( controller: _bodyHController, scrollDirection: Axis.horizontal, child: SizedBox( width: TableConfig.totalWidth, child: ListView.builder( controller: _bodyVController, itemCount: rows.length, itemExtent: TableConfig.rowHeight, itemBuilder: (context, index) => Row( children: List.generate( TableConfig.colWidths.length, (i) => Container( width: TableConfig.colWidths[i], alignment: Alignment.center, child: Text(rows[index].cells[i]), ), ), ), ), ), ); }注意SizedBox的宽度必须是所有列宽之和,否则横向滚动范围不对,联动也会出问题。
4.6 控制器初始化与销毁
控制器在initState里创建,在dispose里释放。监听器也要在dispose里移除,虽然控制器销毁时会自动清理,但显式移除是好习惯。
late ScrollController _headerHController; late ScrollController _bodyHController; late ScrollController _leftVController; late ScrollController _bodyVController; @override void initState() { super.initState(); _headerHController = ScrollController(); _bodyHController = ScrollController(); _leftVController = ScrollController(); _bodyVController = ScrollController(); _bodyHController.addListener(_syncHorizontal); _bodyVController.addListener(_syncVertical); } void _syncHorizontal() { if (_headerHController.hasClients && _headerHController.offset != _bodyHController.offset) { _headerHController.jumpTo(_bodyHController.offset); } } void _syncVertical() { if (_leftVController.hasClients && _leftVController.offset != _bodyVController.offset) { _leftVController.jumpTo(_bodyVController.offset); } } @override void dispose() { _bodyHController.removeListener(_syncHorizontal); _bodyVController.removeListener(_syncVertical); _headerHController.dispose(); _bodyHController.dispose(); _leftVController.dispose(); _bodyVController.dispose(); super.dispose(); }4.7 参数计算与对齐验证
列宽总和、行高、表头高度这些参数定好之后,要验证几个对齐点。第一,左上角固定块的宽度必须等于左侧列宽,高度等于表头高度。第二,顶部固定行的总宽度必须等于主内容区总宽度。第三,左侧固定列的总高度必须等于主内容区总高度。
验证方法很简单:在调试模式下给各个区域加不同颜色的边框,滑动到边界看是否对齐。我一般用Container(decoration: BoxDecoration(border: Border.all(color: Colors.red)))临时标记,确认无误后去掉。
| 对齐项 | 左侧值 | 右侧值 | 是否必须相等 |
|---|---|---|---|
| 表头高度 | 左上块高度 | 顶部行高度 | 是 |
| 左侧列宽 | 左上块宽度 | 左侧列宽度 | 是 |
| 横向总宽 | 顶部行内容宽 | 主内容区内容宽 | 是 |
| 纵向总高 | 左侧列内容高 | 主内容区内容高 | 是 |
| 行高 | 左侧列每项高 | 主内容区每行高 | 是 |
5. 常见问题与排查技巧实录
5.1 滑动不同步的三种典型原因
第一种,监听器没挂上或者挂错了控制器。检查addListener是不是加在了主内容区的控制器上,而不是固定行列的控制器上。第二种,hasClients判断缺失导致首次滑动时同步失败。第三种,两边内容总宽度或总高度不一致,导致offset范围不同,滑到后面就错位了。
排查方法:在监听回调里打印两个控制器的offset,滑动时观察数值是否一致。如果不一致,看是哪个控制器没跟上,再检查它的滚动视图内容尺寸。
5.2 边界回弹导致错位的处理
iOS 默认有回弹效果,横向滑到最右边还能再拉一段。这时候主内容区的offset会超过最大值,而顶部固定行的offset最大值是固定的,jumpTo一个超范围的值会抛异常或者被截断,导致回弹结束后两边对不齐。
解决办法是给固定行列的滚动视图也设置相同的physics,或者干脆把主内容区的physics设为ClampingScrollPhysics,禁用回弹。如果产品要求保留回弹,那就在同步时对offset做clamp处理:
final max = _headerHController.position.maxScrollExtent; final target = _bodyHController.offset.clamp(0.0, max); _headerHController.jumpTo(target);5.3 数据更新后布局错乱
当行数或列数动态变化时,比如筛选后数据变少,控制器可能还保持着旧的offset,导致滚动位置越界。这时候需要在数据更新后调用jumpTo(0)重置,或者判断当前offset是否超过新的maxScrollExtent,超过就归零。
另外,如果列宽是动态计算的,数据更新后要重新计算总宽度,SizedBox的宽度也要跟着变,否则横向滚动范围不对。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决方式 |
|---|---|---|---|
| 横向滑动顶部行不动 | 监听器未挂载 | 检查 addListener | 挂到主内容区横向控制器 |
| 纵向滑动左侧列不动 | 监听器未挂载 | 检查 addListener | 挂到主内容区纵向控制器 |
| 滑动到底部错位 | 内容总尺寸不一致 | 对比两边总宽/总高 | 统一列宽行高常量 |
| 首次滑动卡顿 | hasClients 未判断 | 看是否抛异常 | 加 hasClients 判断 |
| 回弹后错位 | offset 超范围 | 打印 offset 值 | clamp 处理或禁用回弹 |
| 数据更新后空白 | offset 越界 | 检查 maxScrollExtent | 重置 offset 为 0 |
| 列头与内容错位 | 列宽不一致 | 对比列宽定义 | 统一从常量列表取值 |
5.5 几个我踩过的坑
第一个坑是在itemBuilder里动态计算宽度。一开始我为了灵活,每列的宽度根据内容长度算,结果顶部行和主内容区算出来的宽度有细微差异,滑动时列慢慢就偏了。后来改成统一常量列表,问题消失。
第二个坑是用animateTo做联动。看起来平滑,但快速滑动时固定行列跟不上,有明显的延迟感,用户体验很差。换成jumpTo后跟手感立刻正常。
第三个坑是忘记处理dispose。页面退出时控制器没释放,反复进出几次后内存上涨。虽然 Flutter 有 GC,但控制器持有监听器引用,不释放会拖慢回收。养成dispose里清理的习惯。
提示:如果你的表格还需要支持点击单元格、长按选中、拖拽排序等交互,建议把单元格抽成独立组件,通过回调把事件传出去,避免在
itemBuilder里堆太多逻辑导致重建开销大。
6. 性能优化与扩展思路
6.1 减少不必要的重建
主内容区的每一行如果包含复杂组件,横向滚动时整行都会重建。优化手段是用RepaintBoundary把每一行包起来,让 Flutter 知道这一行的绘制是独立的,横向滚动时不需要重绘其他行。另外,单元格内容如果不变,可以用const构造函数创建,减少重建开销。
itemBuilder: (context, index) => RepaintBoundary( child: _buildRow(rows[index]), ),6.2 固定列宽下的懒加载策略
横向如果列特别多,比如上百列,即使有SizedBox撑开总宽度,Row里一次性生成所有列也会卡。这时候可以把横向也改成ListView.builder,但要注意横向ListView和纵向ListView嵌套时,需要给横向的指定高度,给纵向的指定宽度,否则约束会冲突。
我的建议是:列数在五十以内,直接用Row铺开,简单可靠;超过五十列,考虑横向也用ListView.builder,但联动逻辑要相应调整,因为横向滚动源变成了每一行内部的滚动视图,需要额外做行间同步。
6.3 后续可扩展的方向
这套骨架搭好之后,可以往上加不少东西。比如表头支持点击排序,点击后在列头显示升降序图标;比如左侧列支持展开折叠分组;比如单元格支持编辑,点击后弹出输入框。这些都是在现有结构上叠加交互层,不影响核心的滚动联动逻辑。
还有一个方向是支持冻结多列。现在的方案只冻结了最左侧一列,如果要冻结前两列,只需要把左侧固定列拆成两个并排的固定列,宽度分别定义,联动逻辑不变。原理是一样的,只是多了一个跟随者。
6.4 实测性能数据参考
我在一台中端安卓设备上测过,五百行、十列的数据,用这套方案滑动帧率稳定在五十五到六十之间,没有明显掉帧。如果去掉RepaintBoundary,帧率会掉到四十五左右,横向快速滑动时能感觉到轻微卡顿。所以RepaintBoundary这个优化点虽然简单,但效果立竿见影。
内存方面,五百行数据常驻大概占用十几兆,属于正常范围。如果行数上千,建议做分页加载,不要一次性全渲染。
我个人在实际项目里用这套方案做过好几个看板类页面,最复杂的一个是左侧固定两列、顶部固定两行、右下区域横竖都滚动,联动逻辑稍微扩展了一下就搞定了。核心思路始终没变:找到滚动源头,让固定区域跟随,保证尺寸对齐。把这三点抓住,剩下的就是细节打磨。