Skip to content

TableView

Edgar Mesquita edited this page Oct 20, 2026 · 4 revisions

Table View

🌐 This page in: English · Português · Español · Français · Deutsch

TableView is a window onto a grid larger than any screen, on BOTH axes, as Flutter's TableView is: you describe how far each row and each column reaches, hand it a builder that answers the cell at a row and a column, and say how many leading rows and columns stay pinned. It builds only the cells in view plus a margin around them, so Excel's whole sheet, 1 048 576 rows by 16 384 columns, costs what a screenful costs. It is write-once: the same C# runs as GPU paint on Photon and as DOM on the web.

TableView(
    rows: new UniformTableSpans(1_048_576 + 1, 28),
    columns: new SparseTableSpans(16_384 + 1, 96, new Dictionary<int, double> { [0] = 48, [3] = 180 }),
    cellBuilder: (row, column) => Text(row == 0 ? $"{column}" : $"{row}:{column}", TypeRole.Caption),
    pinnedRows: 1,
    pinnedColumns: 1,
    width: SizeValue.Fill,
    height: SizeValue.Fill,
    label: "Sheet")

The dashboard sample's /table screen is this grid with each cell showing its address, a header row and a column of row numbers pinned.

Rows and columns

Each axis is a TableSpans: how many there are, how far they reach in all, and the two questions the viewport asks, where index i starts (OffsetOf) and which index is at an offset (IndexAt).

  • UniformTableSpans(count, extent): every row (or column) reaches as far. Every offset is one multiplication.
  • SparseTableSpans(count, extent, extents): a default extent, and the few that have their own, as a sheet where someone widened column D. The overrides are kept sorted with a running sum, so an offset costs a binary search over the overrides, however long the axis.
  • Your own: derive from TableSpans and answer Count, Total, ExtentOf, OffsetOf and IndexAt.

Offsets are double on both targets: a million rows of 28 reach 29 360 128, past where a float still counts single points.

The window it builds

The table builds the rows and columns the viewport shows plus overscan (3 by default) past each edge. The window has hysteresis: a scroll that keeps what is visible inside the cells already built builds nothing, and the viewport moves those cells by itself. The table builds again only when the visible rows or columns leave them.

A cell is keyed by its position ("row:column"), so a cell that stays in the window keeps its element, and its state, when the window moves.

The first frame is built for a common window (1 024 × 768) rather than for nothing, so it shows cells; the viewport then reports its real size and the next frame builds for it.

Pinned rows and columns

pinnedRows and pinnedColumns keep the leading rows at the top and the leading columns at the start while the rest scroll. The viewport draws four bands, each under its own clip: the corner, which never moves; the pinned rows, which move across with the grid; the pinned columns, which move down with it; and the body, which moves both ways. A cell is drawn in the band of its first row and column.

Marks

marks are drawn ABOVE the cells, over the range each covers, and take no press and no focus: a selection, an outline, the band of a find. Each is a TableViewCell made with mark: true, its rows and columns saying how many it covers:

TableViewCell(row: 4, column: 2, Box(new BoxStyle { BorderWidth = 2, BorderColor = theme.FocusRing }),
    mark: true, rows: 3, columns: 2)

A mark that crosses the edge of a pinned band is cut there, one piece per band, so it shows in each band it reaches and hides where it passes under one.

Scrolling

The viewport OWNS its offset, on both targets: the browser scrolls nothing. That is not a taste. A browser draws an element at most 16 777 214 px tall, and Excel's rows reach 29 million, so a grid the browser scrolled could never reach its last row. The table moves its cells by transform instead, and draws its own scrollbars.

  • A wheel and a trackpad move both axes. A trackpad's precise deltas move the grid as the fingers do; a mouse wheel's notch glides to where it lands. Shift turns a vertical wheel across.
  • The scrollbars sit in strips on the end and bottom edges. A thumb drags, and a press on the track moves a page toward the pointer.
  • A finger that travels past a 12-point slop pans the grid, and a lift while it moves flings it. A press that never travels is still a tap on the cell under it.
  • What the grid cannot take at an edge goes on to the scroller around it, so a page keeps scrolling once the grid is at its end.
  • The keyboard: the table is a stop of the Tab order itself, before its cells' controls. With the focus, the arrows move a step, PageUp and PageDown a page, Home and End to the first and the last row, and with Control or Command to the first and the last column too. Tab visits the cells' controls row by row.
  • A mouse or a trackpad's pointer on a phone or a tablet presses the cells, as it does on a desktop; only a finger pans.
  • Reduced motion lands every step at once.

request scrolls on your behalf, once per version:

request: new TableScrollRequest(version) { Reveal = new TableRange(1_048_575, 0) }

Offset puts the grid at an offset; Reveal brings a range into view, clear of the pinned bands. A request is honoured when its Version is new, so handing the same one on every build does nothing twice. It holds until the person moves the grid, so a layout that settles in two passes, a row that measures the table before and after its fixed siblings, still lands it where the last pass says.

onScrolled says where the grid stands (a TableOffset, across and down) and onViewportChanged how large the part the cells scroll in is, each once per change.

Giving it a window

A table IS a window, so give it one: a fixed size, or SizeValue.Fill inside something that has a size. A Fill axis with no bound, which is what a page that scrolls on that axis hands its content, takes the visible extent of the nearest scroller around it, or the window's, and the development log says so once, naming the extent it took. Its own content never decides its size, and a width of its own wider than the room its parent gives is held to that room, on both targets.

Accessibility

The viewport is a group named by label, over the cells it holds. The cells it does not hold are not in the tree at all, on either target. Tab reaches the controls in the cells it holds, and one the focus lands on past the window is scrolled into view, clear of the pinned bands. A mark is never a stop of the Tab order, and a screen reader does not reach it.

Under the component

TableView is a component over a node of the vocabulary, TableViewport, which places the TableViewCells it is handed and carries the scroll model (ITableScrollModel, implemented by TableScroll). A component that decides its own window, as the Spreadsheet will, can compose the node directly with the same model: the realizers feed the model the wheel, the pointer and the clock, and draw what it answers.

Clone this wiki locally