You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter实现多个水平ListView横向同步滚动且分隔符不随动

Flutter 嵌套列表横向同步滚动实现方案

问题背景

实现外层垂直滚动、内层水平滚动的嵌套列表布局,需满足以下要求:

  • 外层垂直列表支持正常上下滚动
  • 横向滚动任意一行内容时,所有行同步横向滚动,始终保持列对齐
  • 行与行之间的分隔符固定在垂直列表中,不跟随横向滚动位移,因此无法通过在外层包裹横向SingleChildScrollView实现
  • 受Flutter框架限制,同一个ScrollController无法同时绑定多个可滚动组件,直接复用控制器会抛出异常

初始问题代码如下:

class ListViewTest extends StatelessWidget{

   final ScrollController _horizontalScrollController = ScrollController(); 
  
  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      itemCount: 20,
      separatorBuilder: (BuildContext context, int i) => const Divider(
        thickness: 1,
        color: Colors.black,
        height: 41,
      ),
      itemBuilder: (BuildContext context, int i) => SizedBox(
        height: 40,
        child: ListView.separated(
          //controller: _horizontalScrollController, does not work
          shrinkWrap: true,
          scrollDirection: Axis.horizontal,
          itemCount: 10,
          separatorBuilder: (context, j) => SizedBox(width: 5,),
          itemBuilder: (context, j) =>  Container(
            height: 40,
            width: 60,
            color: Colors.green,
            child: Text(
              "cell $i - $j"
            ),
          ),
        ),
      ),
    );
  }
}

效果参考:
横向同步滚动表格效果参考

实现思路

核心逻辑是放弃「单控制器绑定多组件」的思路,改为多控制器+偏移同步的方案:

  • 为每一行的水平列表单独创建专属ScrollController,统一存放在列表中管理
  • 给所有控制器添加滚动监听,当任意一行触发横向滚动时,记录当前滚动偏移
  • 增加同步锁标记,避免滚动回调循环触发,遍历所有其他行的控制器,统一更新滚动位置到记录的偏移值
  • 分隔符始终放在外层垂直列表的构建逻辑中,不进入横向滚动区域,自然不会产生横向位移

完整可运行代码

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

  @override
  State<SyncScrollTableList> createState() => _SyncScrollTableListState();
}

class _SyncScrollTableListState extends State<SyncScrollTableList> {
  // 存储所有行的横向滚动控制器
  final List<ScrollController> _rowScrollControllers = [];
  // 滚动同步锁,防止回调循环触发
  bool _isSyncing = false;
  // 全局统一的横向滚动偏移
  double _syncOffset = 0;

  @override
  void dispose() {
    // 组件销毁时释放所有控制器,避免内存泄漏
    for (final controller in _rowScrollControllers) {
      controller.dispose();
    }
    super.dispose();
  }

  /// 按行索引获取对应滚动控制器,懒初始化
  ScrollController _getRowController(int rowIndex) {
    // 索引超过已存储长度时新建控制器
    if (rowIndex >= _rowScrollControllers.length) {
      final controller = ScrollController(initialScrollOffset: _syncOffset);
      controller.addListener(() {
        // 同步过程中触发的回调直接跳过
        if (_isSyncing) return;
        // 更新全局偏移值
        _syncOffset = controller.offset;
        _isSyncing = true;
        // 同步所有其他行的滚动位置
        for (final c in _rowScrollControllers) {
          if (c != controller && c.hasClients) {
            c.jumpTo(_syncOffset);
          }
        }
        _isSyncing = false;
      });
      _rowScrollControllers.add(controller);
    }
    return _rowScrollControllers[rowIndex];
  }

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      itemCount: 20,
      // 分隔符在外层构建,不属于横向滚动区域,固定不横向移动
      separatorBuilder: (BuildContext context, int index) => const Divider(
        thickness: 1,
        color: Colors.black,
        height: 41,
      ),
      itemBuilder: (BuildContext context, int rowIndex) => SizedBox(
        height: 40,
        child: ListView.separated(
          controller: _getRowController(rowIndex),
          scrollDirection: Axis.horizontal,
          physics: const ClampingScrollPhysics(),
          itemCount: 10,
          separatorBuilder: (context, colIndex) => const SizedBox(width: 5),
          itemBuilder: (context, colIndex) => Container(
            height: 40,
            width: 60,
            color: Colors.green,
            alignment: Alignment.center,
            child: Text("cell $rowIndex - $colIndex"),
          ),
        ),
      ),
    );
  }
}

优化说明

  • 如果需要同步滚动时带平滑动画效果,可将代码中的jumpTo替换为animateTo,传入统一的滚动时长和动画曲线即可
  • 如果列表行数极大(百级以上),可结合外层ListView的懒加载生命周期,在row组件销毁时同步移除并销毁对应的ScrollController,降低内存占用
  • 若需要固定首列/尾列不跟随横向滚动,可在每一行的横向列表外拼接固定位置的列组件即可,逻辑不受同步滚动影响

注意:水平ListView无需设置shrinkWrap: true,外层SizedBox已经明确给出了高度约束,开启shrinkWrap会导致横向列表一次性加载全部子组件,长列表场景下会引发性能问题。

内容的提问来源于stack exchange,提问作者Haissem55

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.27 04:27:19