DataTable

@Composable
fun <T> DataTable(items: List<T>, headers: List<DataTableHeader<T>>, itemKey: (T) -> Any, modifier: Modifier = Modifier, state: DataTableState = rememberDataTableState(), showSelect: Boolean = false, selectionMode: SelectionMode = if (showSelect) SelectionMode.MULTI else SelectionMode.NONE, selectedKeys: Set<Any> = emptySet(), onSelectionChange: (Set<Any>) -> Unit? = null, showExpand: Boolean = false, expandedKeys: Set<Any> = emptySet(), onExpandChange: (Set<Any>) -> Unit? = null, expandContent: @Composable (T) -> Unit? = null, density: DataTableDensity = DataTableDensity.DEFAULT, colors: DataTableColors = DataTableDefaults.colors(), textStyles: DataTableTextStyles = DataTableDefaults.textStyles(), sortBy: SortState = SortState(), onSortChange: (SortState) -> Unit? = null, multiSortBy: List<SortState> = emptyList(), onMultiSortChange: (List<SortState>) -> Unit? = null, manualSorting: Boolean = false, filters: Map<String, String> = emptyMap(), onFiltersChange: (Map<String, String>) -> Unit? = null, manualFiltering: Boolean = false, resizableColumns: Boolean = false, minColumnWidth: Dp = 40.dp, reorderableColumns: Boolean = false, rowHeight: Dp? = DataTableDefaults.rowHeight(density), hideDefaultHeader: Boolean = false, hideDefaultFooter: Boolean = false, loading: Boolean = false, loadingContent: @Composable () -> Unit? = null, headerContent: @Composable () -> Unit? = null, footerContent: @Composable () -> Unit? = null, noDataContent: @Composable () -> Unit? = null, noResultsContent: @Composable () -> Unit? = null, showColumnMenuButton: Boolean = false, groupBy: (T) -> String? = null, groupHeaderContent: @Composable (String, List<T>) -> Unit? = null, groupSummaryContent: @Composable (String, List<T>) -> Unit? = null, onRowClick: (T) -> Unit? = null, onRowDoubleClick: (T) -> Unit? = null, onRowContextMenu: (T, Offset) -> Unit? = null, showPagination: Boolean = false, itemsPerPage: Int = 10, currentPage: Int = 0, onPageChange: (Int) -> Unit? = null, itemsPerPageOptions: List<Int> = listOf(10, 25, 50, 100), onItemsPerPageChange: (Int) -> Unit? = null, manualPagination: Boolean = false, totalItems: Int? = null, cellNavigation: Boolean = false, onCellEdit: (CellEdit<T>) -> Unit? = null, onEditStart: (CellPosition) -> Unit? = null, onEditCancel: (CellPosition) -> Unit? = null, onCopy: (ClipboardSelection<T>) -> Unit? = null, showScrollbars: Boolean = true)(source)

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 manualFiltering

  • Right-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

T

The item type for each row.

itemKey

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.

selectedKeys

Keys of the currently selected rows.

onSelectionChange

Invoked with the new set of selected keys.

expandedKeys

Keys of the currently expanded rows.

onExpandChange

Invoked with the new set of expanded keys.

sortBy

Active single-column sort. Read only when onSortChange is supplied.

onSortChange

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.

multiSortBy

Active multi-column sort. Controlled by onMultiSortChange the same way.

manualSorting

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.

filters

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.

onFiltersChange

Invoked with the whole new filter map when a filter changes, on every keystroke. Debounce it before turning it into a server query.

manualFiltering

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.

noResultsContent

Shown in place of the rows when filters match nothing, telling that apart from a table that has no data at all.

reorderableColumns

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.

rowHeight

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.
showColumnMenuButton

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.

currentPage

Active zero-based page. Read only when onPageChange is supplied, which makes pagination controlled in the same way as sorting.

manualPagination

When true items is already the current page and the table does not slice it. Requires totalItems.

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.

cellNavigation

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.

onCellEdit

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.

onEditStart

Invoked with the cell's position when an editor opens, by key, click, double-click, or DataTableState.startEditing. Fires once per edit.

onEditCancel

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.

onCopy

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.