Columns¶
Columns are declared with DataTableHeader<T>. One instance per column, in display order.
DataTableHeader<Person>(
key = "salary", // unique column identifier
title = "Salary", // header label
value = { "$${it.salary}" }, // value extractor for display
width = 120.dp, // fixed width (null = fill with weight)
align = TextAlign.End, // text alignment
sortable = true, // enable sorting
fixed = false, // true = frozen/pinned column
visible = true, // toggle visibility
maxLines = 1, // text line limit
overflow = TextOverflow.Ellipsis, // overflow strategy
comparator = compareBy { it.salary }, // custom sort comparator
cellContent = { item -> // custom cell composable
Text("$${String.format("%.2f", item.salary)}")
},
headerContent = { // custom header composable
Text("Salary (USD)", fontWeight = FontWeight.Bold)
},
)
key must be unique — it identifies the column for sorting and for resize overrides.
Width¶
A column is either fixed or weighted:
width |
Behaviour |
|---|---|
120.dp |
Exactly that wide. |
null (default) |
Takes an equal share of the space the fixed columns leave. |
Weighted columns divide up the viewport minus the fixed columns, the selection and expand
controls, and any frozen section. When the fixed columns already overflow the viewport there is
nothing left to share, so each weighted column falls back to minColumnWidth and the table
scrolls.
Mixing both in one table is fine. Mixing them under a single grouped header is not — see Nested headers.
Text overflow¶
The defaults (maxLines = Int.MAX_VALUE, overflow = TextOverflow.Clip) let text wrap, which
gives rows of differing heights. Most tables want the opposite:
DataTableHeader(
key = "notes",
title = "Notes",
value = { it.notes },
width = 300.dp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Custom content¶
cellContent and headerContent replace the default text rendering entirely. This is where the
table stops caring that it has no Material dependency — put whatever you like in a cell.
DataTableHeader(
key = "status",
title = "Status",
width = 150.dp,
cellContent = { item ->
AssistChip(
onClick = {},
label = { Text(item.status) },
)
},
)
Hoist your header list
DataTableHeader is @Immutable, but a list rebuilt inline on every recomposition is still a
new list. Wrap it in remember — keyed on whatever it depends on — so the table can skip
re-sorting:
Visibility¶
visible = false removes a column from rendering without disturbing the rest of the list:
var showNotes by remember { mutableStateOf(true) }
val headers = remember(showNotes) {
listOf(
// ...
DataTableHeader(key = "notes", title = "Notes", value = { it.notes }, visible = showNotes),
)
}
Frozen (pinned) columns¶
fixed = true pins a column to the left edge so it does not scroll horizontally.
DataTableHeader<Person>(
key = "id",
title = "ID",
value = { it.id },
width = 60.dp,
fixed = true,
)
Frozen columns need an explicit width
The frozen section sits outside the horizontally scrolling area, so a weighted column there
has nothing to take a share of. fixed = true without a width throws, naming the column.
Resizable columns¶
Drag the right edge of any header to resize. Overrides live on DataTableState; call
state.resetColumnWidths() to clear them.
Grouped headers resize with their children — a group's width is recomputed from its leaves, so the band stays aligned.
Nested (grouped) headers¶
Give a header children and it becomes a group: a band drawn above its children, spanning
them. Only the leaves render as real columns — sorting, resizing, and cell content all belong to
them.
val headers = listOf(
DataTableHeader<Person>(key = "id", title = "ID", value = { it.id }, width = 60.dp),
DataTableHeader(
key = "contact",
title = "Contact",
children = listOf(
DataTableHeader(key = "email", title = "Email", value = { it.email }, width = 200.dp),
DataTableHeader(key = "phone", title = "Phone", value = { it.phone }, width = 140.dp),
),
),
)
┌──────┬───────────────────────────────┐
│ │ Contact │
│ ID ├───────────────┬───────────────┤
│ │ Email │ Phone │
├──────┼───────────────┼───────────────┤
Groups nest to any depth. A leaf sitting beside a group — ID above — stretches over the full
header height and centres its title, rather than leaving blank rows above it.
A group's own value, width, and sorting settings are ignored. It takes its width from the
leaves beneath it.
Two rules, both enforced
- Leaves under one group must be either all fixed-width or all weighted. A group sums its leaves, and the two sizing models cannot be added together.
- A group cannot straddle the freeze boundary — mark every column under it
fixed = true, or none of them. Half a group would scroll out from under the other half.
Both throw with a message naming the group, rather than misrendering.