Skip to content

[Proposal] TableView: Disable lazy loading for merged cells #174862

Closed
flutter/packages
#11418

Description

@xiabui

Use case

I am currently developing a new feature in my business app using two_dimensional_scrollables package (v0.3.6). It works great for almost all the features and requirements I need. However, I have found an issue related to merged cells.
For example, my table has merged cells spanning 5 or 6 columns (these are not pinned columns). When I scroll the table horizontally and the first cell of the merged group becomes overlaid by a pinned column, it unmerges all the remaining cells. This is not the behavior I expected.
Can you help me with any ideas to avoid this issue?

Proposal

As described in my use case above, I want merged cells to remain intact (not "broken") when overlaid by pinned columns. Below is my sample code:

// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:flutter/material.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';

/// The class demonstrating merged cells in TableView.
class MergedTableExample extends StatefulWidget {
  /// Creates a screen that shows a color palette in the TableView widget.
  const MergedTableExample({super.key});

  @override
  State<MergedTableExample> createState() => _MergedTableExampleState();
}

class _MergedTableExampleState extends State<MergedTableExample> {
  @override
  Widget build(BuildContext context) {
    final Size screenSize = MediaQuery.sizeOf(context);
    return Scaffold(
      body: Padding(
        padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0),
        child: TableView.builder(
          cellBuilder: _buildCell,
          columnCount: 1000,
          pinnedColumnCount: 1,
          columnBuilder: _buildColumnSpan,
          rowCount: 51,
          rowBuilder: _buildRowSpan,
        ),
      ),
    );
  }

  TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
    final int colorIndex = (vicinity.row / 3).floor();
    final TextStyle style = TextStyle(
      color: Colors.black,
      fontSize: 18.0,
      fontWeight: vicinity.column == 0 ? FontWeight.bold : null,
    );
    return TableViewCell(
      rowMergeStart: vicinity.column == 0 ? colorIndex * 3 : null,
      rowMergeSpan: vicinity.column == 0 ? 3 : null,
      columnMergeStart: vicinity.column == 1 ? vicinity.column : null,
      columnMergeSpan: vicinity.column == 1 ? 3 : null,
      child: ColoredBox(
        color: Colors.grey,
        child: Center(child: Text('cell.name', style: style)),
      ),
    );
  }

  TableSpan _buildColumnSpan(int index) {
    return TableSpan(
      extent: FixedTableSpanExtent(index == 0 ? 220 : 50),
      foregroundDecoration: index == 0
          ? const TableSpanDecoration(
              border: TableSpanBorder(
                trailing: BorderSide(width: 5, color: Colors.white),
              ),
            )
          : null,
    );
  }

  TableSpan _buildRowSpan(int index) {
    return TableSpan(
      extent: const FixedTableSpanExtent(120),
      padding: index % 3 == 0 ? const TableSpanPadding(leading: 5.0) : null,
    );
  }
}

Activity

  1. added
    in triagePresently being triaged by the triage team
    packageflutter/packages repository. See also p: labels.
    c: proposalA detailed proposal for a change to Flutter
    p: two_dimensional_scrollablesIssues pertaining to the two_dimensional_scrollables package
    frameworkflutter/packages/flutter repository. See also f: labels.
    f: scrollingViewports, list views, slivers, etc.
    c: new featureNothing broken; request for a new capability
    and removed
    in triagePresently being triaged by the triage team
    on Sep 3, 2025
  2. added
    P2Important issues not at the top of the work list
    and removed
    c: new featureNothing broken; request for a new capability
    c: proposalA detailed proposal for a change to Flutter
    on Oct 7, 2025
  3. self-assigned this
    on Apr 1, 2026
  4. Piinks commented on Apr 2, 2026

    @Piinks
    Contributor

    I think this issue was fixed. Merged cells in the table scroll under pinned cells cleanly, the is no braking of the merging of the cell in response to lazy loading or pinning.

    Runnable sample (same as above, just made it runnable):

    // Copyright 2013 The Flutter Authors. All rights reserved.
    // Use of this source code is governed by a BSD-style license that can be
    // found in the LICENSE file.
    
    import 'package:flutter/material.dart';
    import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
    
    void main() {
      runApp(MaterialApp(home: MergedTableExample()));
    }
    
    /// The class demonstrating merged cells in TableView.
    class MergedTableExample extends StatefulWidget {
      /// Creates a screen that shows a color palette in the TableView widget.
      const MergedTableExample({super.key});
    
      @override
      State<MergedTableExample> createState() => _MergedTableExampleState();
    }
    
    class _MergedTableExampleState extends State<MergedTableExample> {
      @override
      Widget build(BuildContext context) {
        final Size screenSize = MediaQuery.sizeOf(context);
        return Scaffold(
          body: Padding(
            padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0),
            child: TableView.builder(
              cellBuilder: _buildCell,
              columnCount: 1000,
              pinnedColumnCount: 1,
              columnBuilder: _buildColumnSpan,
              rowCount: 51,
              rowBuilder: _buildRowSpan,
            ),
          ),
        );
      }
    
      TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
        final int colorIndex = (vicinity.row / 3).floor();
        final TextStyle style = TextStyle(
          color: Colors.black,
          fontSize: 18.0,
          fontWeight: vicinity.column == 0 ? FontWeight.bold : null,
        );
        return TableViewCell(
          rowMergeStart: vicinity.column == 0 ? colorIndex * 3 : null,
          rowMergeSpan: vicinity.column == 0 ? 3 : null,
          columnMergeStart: vicinity.column == 1 ? vicinity.column : null,
          columnMergeSpan: vicinity.column == 1 ? 3 : null,
          child: ColoredBox(
            color: Colors.grey,
            child: Center(child: Text('cell.name', style: style)),
          ),
        );
      }
    
      TableSpan _buildColumnSpan(int index) {
        return TableSpan(
          extent: FixedTableSpanExtent(index == 0 ? 220 : 50),
          foregroundDecoration: index == 0
              ? const TableSpanDecoration(
                  border: TableSpanBorder(
                    trailing: BorderSide(width: 5, color: Colors.white),
                  ),
                )
              : null,
        );
      }
    
      TableSpan _buildRowSpan(int index) {
        return TableSpan(
          extent: const FixedTableSpanExtent(120),
          padding: index % 3 == 0 ? const TableSpanPadding(leading: 5.0) : null,
        );
      }
    }

    And behavior demo:

    Screen.Recording.2026-04-01.at.7.12.02.PM.mov
  5. Piinks commented on Apr 2, 2026

    @Piinks
    Contributor

    Actually, I found an edge case. If I change the cahceExtent to 0 I can trigger this. Sending a fix

  6. reopened this on Apr 2, 2026
  7. Piinks commented on Apr 2, 2026

    @Piinks
    Contributor

    Hi @xiabui! After taking a look, the behavior you're seeing happens because of how TableView lazily builds its cells. To optimize performance, the table only builds the cells currently visible in the viewport (and those within the cacheExtent).

    In your _buildCell method, you are only providing merge information when the vicinity.column is exactly 1:

    TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
      // ...
      return TableViewCell(
        // ...
        // Only provided for column 1
        columnMergeStart: vicinity.column == 1 ? vicinity.column : null, 
        columnMergeSpan: vicinity.column == 1 ? 3 : null,
        child: ColoredBox(
          color: Colors.grey,
          child: Center(child: Text('cell.name', style: style)),
        ),
      );
    }

    When you scroll so that column 1 is no longer being built (e.g., it's behind a pinned column or off-screen), the table starts building at column 2. Since column 2 doesn't return any merge information, the table treats it as a standard, unmerged cell, causing the "unmerge" effect you're seeing. This is not the intended use of the API.

    The Fix

    To ensure merged cells remain intact, you must return the same child and the same merge information for every vicinity that is part of the merge.

    Here is an updated version of your _buildCell that correctly handles the merge for columns 1, 2, and 3:

    TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
      final int colorIndex = (vicinity.row / 3).floor();
      final TextStyle style = TextStyle(
        color: Colors.black,
        fontSize: 18.0,
        fontWeight: vicinity.column == 0 ? FontWeight.bold : null,
      );
    
      // Define the merge boundaries
      const int mergeStartColumn = 1;
      const int mergeSpan = 3;
      final bool isInColumnMerge = vicinity.column >= mergeStartColumn && 
                                   vicinity.column < mergeStartColumn + mergeSpan;
    
      return TableViewCell(
        rowMergeStart: vicinity.column == 0 ? colorIndex * 3 : null,
        rowMergeStart: vicinity.column == 0 ? 3 : null,
        // Provide merge info for ALL columns in the range
        columnMergeStart: isInColumnMerge ? mergeStartColumn : null,
        columnMergeSpan: isInColumnMerge ? mergeSpan : null,
        child: ColoredBox(
          color: Colors.grey,
          child: Center(child: Text('cell.name', style: style)),
        ),
      );
    }

    I am putting up a small fix to make this more resilient when cells are hidden specifically by pinned rows or columns, and I'm updating the documentation to make the API requirement clearer.

  8. github-actions commented on Apr 16, 2026

    @github-actions

    This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.

  9. locked as resolved and limited conversation to collaborators on Apr 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P2Important issues not at the top of the work listf: scrollingViewports, list views, slivers, etc.frameworkflutter/packages/flutter repository. See also f: labels.p: two_dimensional_scrollablesIssues pertaining to the two_dimensional_scrollables packagepackageflutter/packages repository. See also p: labels.team-frameworkOwned by Framework teamtriaged-frameworkTriaged by Framework teamwaiting for PR to land (fixed)A fix is in flight

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions