Cell Editing¶
The table edits cells in place, and it never touches your data while doing it. A commit is
reported through onCellEdit; you apply it to your own model and pass the updated items back.
That is what keeps one source of truth in your state — and what makes undo, dirty tracking, and
server round-trips possible at all.
var people by remember { mutableStateOf(initialPeople) }
val headers = listOf(
DataTableHeader<Person>(key = "name", title = "Name", value = { it.name }, width = 200.dp),
DataTableHeader(key = "role", title = "Role", value = { it.role }, width = 160.dp,
editable = true,
),
)
DataTable(
items = people,
headers = headers,
itemKey = { it.id },
onCellEdit = { edit ->
people = people.map { person ->
if (person.id == edit.rowKey && edit.columnKey == "role") {
person.copy(role = edit.newText)
} else {
person
}
}
},
)
That is the whole minimum: editable = true on the column, and onCellEdit to apply the result.
The CellEdit<T> it hands you carries five things:
item-
The row that was edited. Matching on
rowKeyas above keeps the example self-contained, but the row itself is right here when your update needs more than its key. rowKey/columnKey-
Which cell it was, named by the key
itemKeyproduced and the column'skey— so an edit reported while the table is sorted, filtered, or paged still points at the right row. oldText/newText-
The value before and after, both
String. ParsingnewTextback into your model's type is your job; see Validation for the guard that makes it safe.
Declaring an editable column turns on cell navigation
Editing needs a cell cursor — an editor you cannot reach with the keyboard is no use. So a
single editable column implies cellNavigation = true, described below. Tables with no
editable columns are untouched and keep navigating by row.
Opening and closing an editor¶
| Key | Action |
|---|---|
| Enter or F2 | Open the editor on the focused cell |
| any printable character | Open the editor, replacing the value with what you typed |
| double-click | Open the editor on that cell |
| Enter | Commit and move down one row — the data-entry move |
| Tab / Shift+Tab | Commit and move to the next / previous cell |
| Esc | Cancel, leaving the row untouched |
Committing a value you did not change reports nothing, so opening and closing a cell never marks a row dirty.
Space is not a shortcut into the editor — it stays bound to row selection. Press F2 first if you need to type a value that begins with a space.
Double-click is yours if you want it
Supplying onRowDoubleClick claims the gesture: the table stops opening editors on
double-click and calls you instead. Enter, F2, and type-to-edit still work, so a
detail dialog and in-place editing can coexist in one table.
Formatting versus editing¶
Formatted text is rarely text you can type back — nobody wants to edit $1,250.00 down to a
number before changing it. Keep the display in format and the editor
opens on the raw value by itself:
DataTableHeader<Invoice>(
key = "amount",
title = "Amount",
width = 140.dp,
align = TextAlign.End,
value = { it.amount }, // 1250.0 — what the editor opens on
format = DataTableFormatters.currency(decimals = 2), // $1,250.00 — what the cell reads
editable = true,
)
editValue is for the case left over: when even the raw value's toString() is not what you want
typed. An amount held as a Double opens on 1250.0; if the column takes whole units, say so:
CellEdit.oldText is reported from the same text the editor opened with, so an unchanged commit
is correctly recognised as a non-edit.
Validation¶
validateEdit returns an error message to reject a value, or null to accept it. A rejected
commit keeps the editor open, puts the message on state.editError, and outlines the cell in
colors.invalidCellBorder. Nothing reaches onCellEdit.
DataTableHeader<OrderLine>(
key = "quantity",
title = "Quantity",
value = { it.quantity },
width = 120.dp,
editable = true,
validateEdit = { line, text ->
val quantity = text.toIntOrNull()
when {
quantity == null -> "Enter a whole number"
quantity < 0 -> "Quantity cannot be negative"
quantity > line.available -> "Only ${line.available} in stock"
else -> null
}
},
)
onCellEdit hands you a String, so parsing it back into your model's type is your job. Pair
every typed column with a validateEdit that proves the parse succeeds, and the toInt() in your
onCellEdit can never throw.
The message is on the state, so you choose where it appears — a banner, a status bar, a tooltip:
Custom editors¶
A column whose values come from a fixed set deserves a picker, not free text. editorContent
replaces the built-in text field with any composable, handed the row and a CellEditController:
DataTableHeader<Employee>(
key = "department",
title = "Department",
value = { it.department },
width = 170.dp,
editable = true,
editorContent = { employee, controller ->
var expanded by remember { mutableStateOf(true) }
Box {
Text(employee.department)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false; controller.cancel() },
) {
departments.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = { expanded = false; controller.commit(option) },
)
}
}
}
},
)
controller.commit(text) runs the column's validateEdit and reports through onCellEdit just
as the built-in editor does — validation cannot be bypassed by supplying your own editor.
controller.initialText is what the cell holds (or what the user typed to open it), and
controller.error carries a rejection message so the editor can show it.
A custom editor is expected to take keyboard focus itself. It commits in place; unlike Enter and Tab in the built-in field, it does not move the cursor afterwards.
Ready-made editors¶
DataTableEditors covers the common non-text columns so they need no hand-written
editorContent:
DataTableHeader<Employee>(
key = "department",
value = { it.department },
editable = true,
cellContent = DataTableEditors.dropdownCell { it.department },
editorContent = DataTableEditors.dropdown(listOf("Finance", "IT", "Sales")),
)
DataTableHeader<Employee>(
key = "active",
value = { it.active },
editable = true,
editOnClick = true,
cellContent = DataTableEditors.checkboxCell { it.active },
editorContent = DataTableEditors.checkbox(),
)
The dropdown opens under the cell, at least as wide as it, with the current value ticked and
highlighted. Up, Down, Home, End, and hovering move the highlight; typing jumps to
the first option starting with the letters typed, and typing quickly builds a longer prefix.
Enter or a click commits, and Esc cancels. dropdownCell adds a chevron to the cell so
the column reads as a picker. The checkbox editor flips the value the moment it opens, so Enter,
F2, a double-click, or, with editOnClick = true, a single click toggles in one step. Both
report "true" / "false" or the chosen
option through onCellEdit like any other edit.
Edit lifecycle¶
Besides onCellEdit, the table reports when an edit begins and when it is abandoned:
DataTable(
// ...
onEditStart = { position -> log("editing ${position.columnKey}") },
onEditCancel = { position -> log("cancelled ${position.columnKey}") },
)
onEditStart fires once per edit, however it began: a key, a click, a double-click, typing over
the cell, or startEditing. onEditCancel fires when an editor closes without a commit: Esc,
a custom editor's controller.cancel(), a click away on a value that fails validation, or
cancelEditing(). A commit always reports through onCellEdit instead, and a commit that
changed nothing reports nothing at all, so a start is followed by exactly one of: a cell edit,
a cancel, or neither for an unchanged commit.
Programmatic editing¶
tableState.startEditing(rowKey = person.id, columnKey = "role")
tableState.cancelEditing()
val editing: CellPosition? = tableState.editingCell
val isEditing: Boolean = tableState.isEditing
startEditing moves cell focus and opens the editor, exactly as F2 does. Use it to drop the
user straight into the first cell of a row they have just added.
Cell navigation¶
cellNavigation = true moves keyboard focus down from the row to the cell, without any column
being editable. The focused cell draws a cursor, clicking a cell focuses it, and:
| Key | Action |
|---|---|
| Left / Right | Move a column |
| Up / Down | Move a row, staying in the same column |
| Home / End | First / last column of the row |
| Ctrl+Home / Ctrl+End | First / last cell of the table |
| Page Up / Page Down | Move a screenful of rows |
| Tab / Shift+Tab | Next / previous cell, wrapping across rows |
Moving onto a column that is scrolled out of view scrolls it in, so Right keeps working across a sixty-column grid.
Tab deliberately falls through at the very first and very last cell rather than wrapping around, so it still gets the user out of the table.
Holding Shift with any of these keys extends a selected block of cells instead of moving the cursor alone, and Ctrl+C copies it — see Range selection.
Focus is held as a row key and a column key, not as coordinates — state.focusedCell is a
CellPosition(rowKey, columnKey). It stays on the same cell across a re-sort, and a cell that
scrolls off a page simply draws nothing rather than the cursor drifting onto whichever row has
taken its place.
val cell = tableState.focusedCell // CellPosition?, null when only a row is focused
tableState.focusCell(person.id, "role") // move it
tableState.focusCell(null, null) // clear it
Like focusRow, focusCell does not scroll. state.revealFocusedCell() is a suspend function
that scrolls both axes to bring it into view.
Limitations¶
- Editing is one cell at a time. A range can be selected and copied — see Selection — but not edited or pasted into as a block.
- No row-level commit. Each cell commits on its own; there is no "editing row" that validates across columns or rolls back as a unit.
- No undo. You hold the data, so undo is a stack of
CellEdits you keep yourself — every commit is reported with botholdTextandnewTextfor exactly that. - The editor is single-line. A multi-line editor means
editorContent.