Data Table
A flexible, Compose Multiplatform data table built entirely on Foundation APIs.
Features:
Column sorting (single and multi-column with Ctrl+click)
Row selection (none, single, multi)
Row expansion with custom content
Row hover highlight and alternating row colors
Frozen/pinned columns
Nested (grouped) headers, to any depth
Column resizing via drag handles
Grouping with custom group header and summary rows
Pagination with configurable items-per-page
Column visibility toggle, with an optional built-in column menu
Text overflow / ellipsis per column
Per-column display formatting, leaving values raw for sorting and editing
Custom sort comparators
A filter row under the header, client-side or handed off with
manualFilteringRight-click context menu callback
Keyboard navigation (arrow keys, Enter, Space, Home, End)
Cell-level focus and grid keyboard navigation
Cell range selection and clipboard copy
In-place cell editing, with validation and custom editors
Uniform row height, and the horizontal virtualization that a known row height allows
Programmatic scroll-to-row via DataTableState
Customizable colors and text styles without any theming framework
Selection and expansion are tracked by row key rather than by item. Callers hoist a Set<Any> of keys produced by itemKey, so both survive item instances being replaced (a refresh from a repository, for example) and neither requires the item type to implement equals/hashCode.
Parameters
The item type for each row.
Produces a stable, unique key per row — a database id or similar. Used for LazyColumn item identity and as the identity of a row in selectedKeys and expandedKeys. Keys must be unique across items: two rows sharing a key select, expand, and recycle as one row.
Keys of the currently selected rows.
Invoked with the new set of selected keys.
Keys of the currently expanded rows.
Invoked with the new set of expanded keys.
Active single-column sort. Read only when onSortChange is supplied.
Invoked when a header is clicked. Supplying it makes sorting controlled: the table renders sortBy and never changes it, so the caller must feed the new value back. Leave it null to let the table own sort state.
Active multi-column sort. Controlled by onMultiSortChange the same way.
When true the table does not reorder items — the caller has already sorted them, typically in a database query. Headers still show sort indicators and report clicks.
Active column filters, as column key to query text. Read only when onFiltersChange is supplied, which makes filtering controlled the same way sorting is: the table renders these and never changes them, so the caller must feed the new value back. Leave it null to let the table own its filters. Filtering applies to any visible column with a query, whether or not it draws a field — which is what lets a filter UI of your own drive the table's matching.
Invoked with the whole new filter map when a filter changes, on every keystroke. Debounce it before turning it into a server query.
When true the table does not filter items — the caller has already done it, typically in the same query that sorted and paged them. The filter row still renders and still reports what the user types.
Shown in place of the rows when filters match nothing, telling that apart from a table that has no data at all.
Lets a user drag a header to a new position, with a line marking where it will land. A grouped header is dragged by its band and moves every column under it as one block; a column cannot be dragged out of its group, nor across the freeze boundary. The result is state.columnOrder, so it is saved with the layout.
Height of every data row, defaulting to one line of body text at density's padding. A uniform row height is also what lets the table virtualize horizontally: knowing a row's height without measuring its cells, it composes only the columns near the viewport and replaces each run that scrolled out of sight with one spacer of the same width. That is what stops a forty-column grid composing forty cells for every visible row.
Pass `null` for rows that size to their content, the way a table without a
height does. Columns are then **not** culled, and cannot be: a row that grows
to fit its tallest cell changes height when the tall cell is the one culled,
so rows would jump as you scrolled sideways. Reach for `null` when a column
wraps to a variable number of lines; give a taller `rowHeight` instead when
cells hold something of a fixed size — a chip, an avatar, two lines of text —
and keep the culling.
Note what culling costs where it is on: a `cellContent` running an effect
stops running it once its column leaves. `LazyColumn` already does that to a
row scrolled off the bottom, but a paginated table never scrolls off the
bottom at all. The header and filter rows are never culled — they cost one row
apiece rather than one per data row, and culling the filter row would drop the
focus out of a field mid-scroll.Puts a menu button at the trailing edge of the header listing every column with a checkbox, so a user can show and hide them without any UI of your own. Columns whose header declares visible = false are not offered. Needs the default header — it is drawn as part of it, so hideDefaultHeader or a custom headerContent leaves it out.
Active zero-based page. Read only when onPageChange is supplied, which makes pagination controlled in the same way as sorting.
When true items is already the current page and the table does not slice it. Requires totalItems.
Row count across every page. Defaults to items.size, which is correct unless manualPagination is on — then only the caller knows the true total.
Moves keyboard focus down to the cell: Left/Right walk columns, the focused cell draws a cursor, and clicking a cell focuses it. Declaring any editable column turns this on by itself, since an editor you cannot reach is no use. Off by default, which leaves row navigation exactly as it was.
Invoked when a cell edit is committed, with the old and new text. The table never mutates items — apply the edit to your own model and pass the updated list back. Without this, editing is a no-op that still validates.
Invoked with the cell's position when an editor opens, by key, click, double-click, or DataTableState.startEditing. Fires once per edit.
Invoked with the cell's position when an editor closes without a commit: Escape, a custom editor's cancel, a click away that fails validation, or DataTableState.cancelEditing. A committed edit reports through onCellEdit instead, even one that changed nothing.
Invoked on Ctrl+C with the rows, columns, and cell text under the selection. Leave it null and the table writes tab-separated text to the system clipboard itself; supply it to write a different format, or to copy somewhere else.