Selection & Expansion¶
Both are tracked by row key — the value returned by itemKey — not by the item itself.
That means selection and expansion survive item instances being replaced (a refresh from your
repository, say), and your row type never needs to implement equals/hashCode.
Selection¶
var selectedKeys by remember { mutableStateOf<Set<Any>>(emptySet()) }
DataTable(
items = people,
headers = headers,
itemKey = { it.id }, // this is the selection identity
showSelect = true, // show checkboxes
selectionMode = SelectionMode.MULTI, // NONE, SINGLE, or MULTI
selectedKeys = selectedKeys,
onSelectionChange = { selectedKeys = it },
)
Modes¶
| Mode | Behaviour |
|---|---|
SelectionMode.NONE |
No selection UI. Row clicks still fire onRowClick. |
SelectionMode.SINGLE |
One row at a time. Clicking a selected row deselects it. |
SelectionMode.MULTI |
Multiple rows, with a select-all checkbox in the header. |
selectionMode defaults to MULTI when showSelect = true, and NONE otherwise.
Resolving keys back to items¶
If you need this often, keep a lookup rather than filtering repeatedly:
val byId = remember(people) { people.associateBy { it.id } }
val selectedPeople = selectedKeys.mapNotNull { byId[it] }
Select-all¶
In MULTI mode the header checkbox selects every row in items — not just the current page.
Except under manual pagination
With manualPagination, items is the current
page, so select-all covers only the loaded rows. The table cannot select rows it has never
seen. Implement "select all N matching rows" yourself if you need it.
Expansion¶
Same model, separate set:
var expandedKeys by remember { mutableStateOf<Set<Any>>(emptySet()) }
DataTable(
// ...
itemKey = { it.id },
showExpand = true,
expandedKeys = expandedKeys,
onExpandChange = { expandedKeys = it },
expandContent = { person ->
Column(Modifier.padding(16.dp)) {
Text("Details for ${person.name}")
Text("Email: ${person.email}")
}
},
)
showExpand adds a chevron column at the leading edge. expandContent renders below the row when
it is expanded, spanning the full table width.
Expand-all and collapse-all are just set operations:
Button(onClick = { expandedKeys = people.map { it.id }.toSet() }) { Text("Expand All") }
Button(onClick = { expandedKeys = emptySet() }) { Text("Collapse All") }
Why keys¶
The obvious alternative — Set<Person> — has three problems this design avoids:
- It requires
equals/hashCodeon your row type, which is not always yours to change. - Selection breaks when items are replaced by equal-but-distinct instances, as happens on any refresh that rebuilds the list.
- Checking "is everything selected" means comparing whole objects on every recomposition.
Keys are small, stable, and cheap to compare.