Compose Multiplatform data table with Material 3 look & feel. Includes a core table (table-core), a conditional
formatting add‑on (table-format), and paging integration (table-paging).
- Example
- Modules
- Key features
- Installation
- Compatibility
- Quick start
- Cell editing mode
- Data grouping
- Footer row
- Paging integration (table-paging)
- Conditional formatting (table-format)
- Core API reference (table-core)
- Filters (built‑in types)
- Fast Filters
- Selection
- Checkbox selection with tableData
- Dynamic row height and auto‑width
- Drag-to-scroll
- Custom header icons
- Supported targets
- Third-Party Libraries
- License
Here's what the data table looks like in action:
Live demo: white-wind-llc.github.io/table
table-core: core table (rendering, header, sorting, column resize and reordering, filtering, row selection, i18n, styling/customization; dynamic or fixed row height).table-format: dialog and APIs for rule‑based conditional formatting for cells/rows.table-paging: adapter on top of the core table forPagingData(ua.wwind.paging).
- Header with sort/filter icons (customizable via
TableHeaderDefaults.icons). - Per‑column sorting (3‑state: ASC → DESC → none).
- Data grouping by column with customizable group headers and sticky positioning.
- Footer row with customizable content per column (totals, averages, summaries); supports pinned and scrollable modes.
- Drag & drop to reorder columns in the header.
- Column resize via drag with per‑column min width.
- Filters: text, number (int/double, ranges), boolean, date, enum (single/multi; IN/NOT IN/EQUALS) with built‑in
FilterPanel. - Active filters header above the table (chips + “Clear all”).
- Row selection modes: None / Single / Multiple; optional striped rows.
- Embedded (nested) tables via the
embeddedflag androwEmbeddedslot for building master–detail layouts inside a single table. - Extensive customization via
TableCustomization(background/content color, elevation, borders, typography, alignment). Outer table border is configurable viaborderparameter (custom stroke or disabled entirely). - i18n via
StringProvider(defaultDefaultStrings). - Targets: Android / JVM (Desktop) / JS (Web) / iOS (KMP source sets present; targets enabled via project conventions).
- Pinned columns with configurable side (left/right) and count.
Add repository (usually mavenCentral) and include the modules you need:
dependencies {
implementation("ua.wwind.table-kmp:table-core:1.7.11")
// optional
implementation("ua.wwind.table-kmp:table-format:1.7.11")
implementation("ua.wwind.table-kmp:table-paging:1.7.11")
}The project uses kotlinx-collections-immutable for all table/state collections to ensure predictable, thread-safe
state management and efficient Compose recomposition:
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:<latest-version>")
}Opt‑in to experimental API on call sites that use the table:
@OptIn(ExperimentalTableApi::class)
@Composable
fun MyScreen() { /* ... */
}The following table lists compatibility information for released library versions.
| Version | Kotlin | Compose Multiplatform |
|---|---|---|
| 1.7.4 | 2.3.0 | 1.9.3 |
| 1.4.0 | 2.2.21 | 1.9.3 |
| 1.3.1 | 2.2.21 | 1.9.2 |
| 1.2.1 | 2.2.10 | 1.9.0 |
data class Person(val name: String, val age: Int)
enum class PersonField { Name, Age }val columns = tableColumns<Person, PersonField, PersonTableData> {
column(PersonField.Name, valueOf = { it.name }) {
header("Name")
cell { person, _ -> Text(person.name) }
sortable()
// Enable built‑in Text filter UI in header
filter(TableFilterType.TextTableFilter())
// Auto‑fit to content with optional max cap
autoWidth(max = 500.dp)
// Optional footer with access to table data
footer { tableData ->
Text("Total: ${tableData.displayedPeople.size}")
}
}
column(PersonField.Age, valueOf = { it.age }) {
header("Age")
cell { person, _ -> Text(person.age.toString()) }
sortable()
align(Alignment.End)
filter(
TableFilterType.NumberTableFilter(
delegate = TableFilterType.NumberTableFilter.IntDelegate,
rangeOptions = 0 to 120
)
)
}
}Column options: sortable, resizable, visible, width(min, pref), autoWidth(max), align(...),
rowHeight(min, max), filter(...), groupHeader(...), headerDecorations(...), headerClickToSort(...),
footer(...).
val state = rememberTableState(
columns = columns.map { it.key },
settings = TableSettings(
stripedRows = true,
showActiveFiltersHeader = true,
selectionMode = SelectionMode.Single,
)
)You can also provide initialOrder, initialWidths, initialSort and update from outside using
state.setColumnOrder(...), state.setColumnWidths(...).
@Composable
fun PeopleTable(items: List<Person>) {
Table(
itemsCount = items.size,
itemAt = { index -> items.getOrNull(index) },
state = state,
columns = columns,
onRowClick = { person -> /* ... */ },
)
}Useful parameters: placeholderRow, contextMenu (long‑press/right‑click),
colors = TableDefaults.colors(), icons = TableHeaderDefaults.icons(),
border (outer border stroke; null uses theme default, TableDefaults.NoBorder disables border).
The table supports row‑scoped cell editing with custom edit UI, validation and keyboard navigation.
- Table‑level switch: enable editing via
TableSettings(editingEnabled = true). - Editable table: use
EditableTable<T, C, E>when you need editing support. - Table data parameter: the generic parameter
Erepresents table data (shared state) accessible in headers, footers, and edit cells. This allows passing validation errors, aggregated values, or any other table-wide state. - Editable columns DSL: declare columns with
editableTableColumns<T, C, E> { ... }and per‑celleditCell. - Callbacks: validate and react to edit lifecycle with
onRowEditStart,onRowEditComplete,onEditCancelled. - Keyboard: Enter/Done moves to the next editable cell; Escape cancels editing (desktop targets).
For text editing inside table cells there is a dedicated composable TableCellTextField:
- Focus integration: it is already wired to the table focus system via
syncEditCellFocus()on itsModifier. This ensures that when a row enters edit mode, the correct cell receives focus, and that keyboard navigation (Enter/Done to move to the next editable cell, Escape to cancel) works consistently across targets. - Compact layout: by default it uses reduced paddings and no border to better fit into dense table rows.
- Visual consistency: styles and colors match Material 3 inputs used in the rest of the table UI.
Whenever you build text‑based edit UI for a cell, prefer TableCellTextField over a raw TextField/
BasicTextField. This way you get correct focus behavior and table‑aware UX without any additional setup.
Minimal example with TableCellTextField:
data class Person(val id: Int, val name: String, val age: Int)
// Table data containing displayed items and edit state
data class PersonTableData(
val displayedPeople: List<Person> = emptyList(),
val editState: PersonEditState = PersonEditState(),
)
// Per‑row edit state (validation, errors, etc.)
data class PersonEditState(
val person: Person? = null,
val nameError: String = "",
val ageError: String = "",
)
enum class PersonColumn { NAME, AGE }
val settings = TableSettings(
editingEnabled = true,
rowHeightMode = RowHeightMode.Dynamic,
)
val state = rememberTableState(
columns = PersonColumn.entries.toImmutableList(),
settings = settings,
)
// Editable columns definition
val columns = editableTableColumns<Person, PersonColumn, PersonTableData> {
column(PersonColumn.NAME, valueOf = { it.name }) {
title { "Name" }
cell { person, _ -> Text(person.name) }
// Edit UI for the cell; table decides when to show it
editCell { person, tableData, onComplete ->
var text by remember(person) { mutableStateOf(person.name) }
TableCellTextField(
value = text,
onValueChange = { text = it },
isError = tableData.editState.nameError.isNotEmpty(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { onComplete() }),
)
}
// Footer with access to table data
footer { tableData ->
Text("Total: ${tableData.displayedPeople.size}")
}
}
column(PersonColumn.AGE, valueOf = { it.age }) {
title { "Age" }
cell { person, _ -> Text(person.age.toString()) }
editCell { person, tableData, onComplete ->
var text by remember(person) { mutableStateOf(person.age.toString()) }
TableCellTextField(
value = text,
onValueChange = { input ->
text = input.filter { it.isDigit() }
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { onComplete() }),
)
}
}
}
// Somewhere in your screen
EditableTable(
itemsCount = people.size,
itemAt = { index -> people.getOrNull(index) },
state = state,
columns = columns,
tableData = currentTableData, // your PersonTableData instance
onRowEditStart = { person, rowIndex ->
// Initialize edit state for the row
},
onRowEditComplete = { rowIndex ->
// Validate and persist; return true to exit edit mode, false to keep editing
true
},
onEditCancelled = { rowIndex ->
// Optional: revert in‑memory changes
},
)If you build custom edit content that includes its own text field implementation or composite inputs, you should integrate with the table focus handling. There are two options:
- Use
TableCellTextFielddirectly: this is the recommended and simplest way. It already callssyncEditCellFocus()on itsmodifier, so the cell participates in the table focus chain automatically. - Reuse the focus modifier in custom components: if you must write your own text field wrapper, make sure to apply the same modifier:
@Composable
fun CustomCellEditor(
value: String,
onValueChange: (String) -> Unit,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.syncEditCellFocus(),
)
}The syncEditCellFocus() modifier performs the following table‑specific work:
- Tracks the active edit cell and requests focus when its row/column become active.
- Releases focus and clears selection when editing ends or moves to another cell.
- Coordinates keyboard navigation so that
onCompleteineditCellmoves to the next editable cell and eventually triggersonRowEditComplete.
By either using TableCellTextField or reusing syncEditCellFocus() in your own composables, custom edit UIs stay
consistent with the default table editing behavior.
Runtime behavior:
- Double‑click on an editable cell to enter row edit mode.
- All editable cells in the row render their
editCellcontent. - Press Enter/Done in a cell to call
onComplete()and move to the next editable column. - After the last editable cell,
onRowEditCompleteis invoked; returningfalsekeeps the row in edit mode. - Press Escape to cancel editing and trigger
onEditCancelled(desktop targets).
Group table data by any column to organize and visualize hierarchical relationships:
// Enable grouping programmatically
state.groupBy = PersonField.Department
// Or let users group via header dropdown menu
// (automatically available for all columns)Customize group header appearance and content:
column(PersonField.Department, valueOf = { it.department }) {
header("Department")
cell { person, _ -> Text(person.department) }
// Custom group header renderer
groupHeader { groupValue ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Icon(Icons.Default.Group, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Department: $groupValue",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
}
}Group headers are sticky and remain visible during scrolling. Configure group content alignment via table settings:
val state = rememberTableState(
columns = columns.map { it.key },
settings = TableSettings(
groupContentAlignment = Alignment.CenterStart,
// ... other settings
)
)Display a summary footer row at the bottom of the table with custom content per column. Footer receives table data as a parameter, allowing access to displayed items and other table state:
data class PersonTableData(
val displayedPeople: List<Person>,
val editState: PersonEditState,
)
val columns = tableColumns<Person, PersonField, PersonTableData> {
column(PersonField.Name, valueOf = { it.name }) {
header("Name")
cell { person, _ -> Text(person.name) }
// Footer content with access to table data (Unit for non-editable tables)
footer { tableData ->
Text(
text = "Total: ${tableData.displayedPeople.size}",
fontWeight = FontWeight.Bold
)
}
}
}Configure footer behavior via table settings:
val state = rememberTableState(
columns = columns.map { it.key },
settings = TableSettings(
showFooter = true, // Enable footer display
footerPinned = true, // Pin footer at bottom (default)
// ... other settings
)
)Footer options:
- showFooter: Enable or disable footer row display.
- footerPinned: When
true(default), footer stays visible at the bottom of the table viewport, similar to a sticky header. Whenfalse, footer scrolls with table content. - footerHeight: Customize footer height via
TableDimensions.footerHeight. - footerColors: Customize footer colors via
TableColors.footerContainerColorandTableColors.footerContentColor.
The footer:
- Respects column widths and alignment settings from the main table.
- Supports pinned columns just like header and body rows.
- Synchronizes horizontal scrolling with the rest of the table.
- For embedded tables, footer is always non-pinned and scrolls with content.
@Composable
fun PeoplePagingTable(paging: PagingData<Person>) {
Table(
items = paging,
state = state,
columns = columns,
)
}There is also LazyListScope.handleLoadState(...) to render loading/empty states.
- Build a
TableCustomizationfrom rules viarememberCustomization(rules, matches = ...). Row‑wide rules havecolumns = emptyList(); cell‑specific rules list field keys incolumns. - Use
FormatDialog(...)to create/edit rules (Design / Condition / Fields tabs).
// 1) Rules
val rules = remember {
listOf(
TableFormatRule.new<PersonField, Person>(id = 1, filter = Person("", 0))
)
}
// 2) Matching logic
val customization = rememberCustomization<Person, PersonField, Person>(
rules = rules,
matches = { item, filter -> item.age >= 65 },
)
// 3) Pass customization to the table
Table(
itemsCount = items.size,
itemAt = { index -> items.getOrNull(index) },
state = state,
columns = columns,
customization = customization,
)
// 4) Optional: rules editor dialog
FormatDialog(
showDialog = show,
rules = rules,
onRulesChanged = { /* persist */ },
getNewRule = { id -> TableFormatRule.new<PersonField, Person>(id, Person("", 0)) },
getTitle = { field -> field.name },
filters = { rule, onApply -> /* return list of FormatFilterData for fields */ emptyList() },
entries = PersonField.entries,
key = Unit,
strings = DefaultStrings,
onDismissRequest = { /* ... */ },
)rememberCustomization merges base styles with matching rules into a resulting TableCustomization (background,
content color, text style, alignment, etc.).
- Composable
Table<T, C>: renders header and virtualized rows for read-only tables (tableData = Unit).- Required:
itemsCount,itemAt(index),state: TableState<C>,columns: List<ColumnSpec<T, C, Unit>>. - Slots:
placeholderRow(). - UX:
onRowClick,onRowLongClick,contextMenu(item, pos, dismiss). - Look:
customization,colors = TableDefaults.colors(),icons = TableHeaderDefaults.icons(),strings,shape,border(outer border;null= theme default,TableDefaults.NoBorder= no border). - Scroll: optional
verticalState,horizontalState. - Embedded content:
embeddedflag androwEmbeddedslot let you render nested detail content or even a secondary table inside each row, while still reusing the same table state, filters and formatting rules.
- Required:
- Composable
Table<T, C, E>: overload that accepts custom table data for headers, footers, and edit cells.- Additional parameter:
tableData: E- shared state accessible in headers, footers, custom filters, and edit cells. - All other parameters same as read-only variant.
- Additional parameter:
- Composable
EditableTable<T, C, E>: renders header and virtualized rows with editing support.- Additional parameters:
tableData: E,onRowEditStart,onRowEditComplete,onEditCancelled. - Columns must use
ColumnSpec<T, C, E>withEmatching the tableData type.
- Additional parameters:
- Columns DSL:
tableColumns<T, C, E> { ... }producesList<ColumnSpec<T, C, E>>for read-only tables.editableTableColumns<T, C, E> { ... }producesList<ColumnSpec<T, C, E>>for editable tables.- Column configuration:
- Cell:
cell { item, tableData -> ... }for regular cell content with access to table data (use_if table data is not needed). - Header:
header("Text")orheader(tableData) { ... }; optionaltitle { "Name" }for active filter chips. - Footer:
footer(tableData) { ... }for custom footer cell content with access to table data. - Editing:
editCell { item, tableData, onComplete -> ... }for custom edit UI. - Sorting:
sortable(),headerClickToSort(Boolean). - Filters UI:
filter(TableFilterType.*). - Sizing:
width(min, pref),autoWidth(max),resizable(Boolean),align(Alignment.Horizontal). - Row height hints:
rowHeight(min, max)used whenrowHeightMode = Dynamic.- Decorations:
headerDecorations(Boolean)to hide built‑ins when fully customizing header.
- Decorations:
- Cell:
- Header customization
- When
headerDecorations = true(default), the table places sort and filter icons automatically. - For a fully custom header, set
headerDecorations(false)and use helpers insideheader { ... }:
- When
column(PersonField.Name, valueOf = { it.name }) {
headerDecorations(false)
header {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Name", modifier = Modifier.padding(end = 8.dp))
TableHeaderSortIcon()
TableHeaderFilterIcon()
}
}
sortable()
filter(TableFilterType.TextTableFilter())
}- State:
rememberTableState(columns, initialSort?, initialOrder?, initialWidths?, settings?, dimensions?).- Sorting:
state.setSort(column, order?); currentstate.sort. - Grouping:
state.groupBy(column)to enable grouping;state.groupBy(null)to disable. - Column order/size:
state.setColumnOrder(order),state.resizeColumn(column, Set/Reset),state.setColumnWidths(map). - Auto-width recalculation:
state.recalculateAutoWidths()to manually recompute column widths based on current content measurements. Useful for deferred/paginated data loading where initial auto-width calculation happened on empty data. - Filters:
state.setFilter(column, TableFilterState(...)); current per‑columnstate.filters. - Selection:
state.toggleSelect(index),state.toggleCheck(index),state.toggleCheckAll(count),state.selectCell(row, column).
- Sorting:
- Settings and geometry
TableSettings:isDragEnabled,autoApplyFilters,autoFilterDebounce,stripedRows,showActiveFiltersHeader,selectionMode: None/Single/Multiple,groupContentAlignment,rowHeightMode: Fixed/Dynamic,enableDragToScroll(controls whether drag-to-scroll is enabled; when disabled, traditional scrollbars are used instead),editingEnabled(master switch for cell editing mode),showFooter(enable footer row display),footerPinned(pin footer at bottom or scroll with content),enableTextSelection(wrap table body inSelectionContainerto allow text selection; defaults tofalse),showVerticalDividers(show/hide vertical dividers between columns; defaults totrue),showRowDividers(show/hide horizontal dividers between rows; defaults totrue),showHeaderDivider(show/hide horizontal divider below header; defaults totrue),showFastFiltersDivider(show/hide horizontal divider below fast filters row; defaults totrue).TableDimensions:defaultColumnWidth,defaultRowHeight,footerHeight,checkBoxColumnWidth,verticalDividerThickness,verticalDividerPaddingHorizontal.TableColors: viaTableDefaults.colors(...).
- TextTableFilter: contains/starts/ends/equals.
- NumberTableFilter(Int/Double): gt/gte/lt/lte/equals/not_equals/between + optional range slider via
rangeOptions. - BooleanTableFilter: equals; optional
getTitle(BooleanType). - DateTableFilter: gt/gte/lt/lte/equals/between (uses
kotlinx.datetime.LocalDate). - EnumTableFilter<T: Enum>: in/not_in/equals with
options: List<T>andgetTitle(T). - CustomTableFilter<T, E>: fully custom filter UI and state with access to table data. Implement
CustomFilterRenderer<T, E>for main panel and optional fast filter (both receivetableData: Eparameter), andCustomFilterStateProvider<T>for chip text. Supports data visualizations of any complexity, including dynamic histograms and statistics based on current table data. - DisabledTableFilter: special marker filter type that completely disables filtering for a column while keeping the API contract (no filter UI is rendered for such columns in filter panels and conditional formatting dialogs).
Applying filters to data is app‑specific. Example:
val filtered = remember(items, state.filters) {
items.filter { item ->
// Evaluate your domain against active state.filters
// See `table-sample` for a full example
true
}
}Fast filters provide quick inline filtering directly in a dedicated row below the header. They share the same
TableFilterState as main filters but with simplified UI and pre-set default constraints:
- Location: Rendered as a horizontal row below the header when
settings.showFastFilters = trueand at least one visible column has a filter configured (notnullorDisabledTableFilter). - Synchronized state: Fast filters and main filter panels use the same
state.filters, changes in one immediately reflect in the other. - Default constraints: Each fast filter type uses a sensible default:
TextTableFilter→ CONTAINSNumberTableFilter→ EQUALSBooleanTableFilter→ EQUALS (tri-state checkbox)DateTableFilter→ EQUALS (date picker)EnumTableFilter→ EQUALS (dropdown)CustomTableFilter→ fully custom (implementRenderFastFilteror leave empty)
- Auto-apply: Fast filters always apply changes automatically with debounce (controlled by
settings.autoFilterDebounce).
Fast filters are ideal for quick data exploration and filtering without opening the full filter panel dialog.
SelectionMode.None(default),Single,Multiple.- In Multiple mode, you can handle selection programmatically:
Table(
itemsCount = items.size,
itemAt = { index -> items[index] },
state = state,
columns = columns,
onRowClick = { _ -> state.toggleCheck(/* row index comes from key or context */) }
)The tableData parameter enables implementing custom checkbox-based selection that shares state between cells, headers,
and external UI components. This pattern is useful when you need:
- Custom selection logic independent of the built-in
SelectionMode - A dedicated checkbox column with select-all functionality in the header
- External UI (e.g., floating action bar) that reacts to selection state
- Bulk operations on selected items (delete, export, etc.)
data class Person(val id: Int, val name: String, val age: Int)
enum class PersonColumn { SELECTION, NAME, AGE }
// Table data containing selection state
data class PersonTableData(
val displayedPeople: List<Person> = emptyList(),
val selectedIds: Set<Int> = emptySet(),
val selectionModeEnabled: Boolean = false,
)val columns = tableColumns<Person, PersonColumn, PersonTableData> {
// Checkbox column for selection
column(PersonColumn.SELECTION, valueOf = { it.id }) {
width(48.dp, 48.dp)
resizable(false)
// Cell renders checkbox based on selection state from tableData
cell { person, tableData ->
if (tableData.selectionModeEnabled) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize(),
) {
Checkbox(
checked = person.id in tableData.selectedIds,
onCheckedChange = { onToggleSelection(person.id) },
)
}
}
}
// Header renders tri-state checkbox for select all/none
header { tableData ->
if (tableData.selectionModeEnabled) {
val displayedIds = tableData.displayedPeople.map { it.id }.toSet()
val selectedCount = displayedIds.count { it in tableData.selectedIds }
val toggleState = when (selectedCount) {
0 -> ToggleableState.Off
displayedIds.size -> ToggleableState.On
else -> ToggleableState.Indeterminate
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize(),
) {
TriStateCheckbox(
state = toggleState,
onClick = { onToggleSelectAll() },
)
}
}
}
}
// Other columns...
column(PersonColumn.NAME, valueOf = { it.name }) {
title { "Name" }
cell { person, _ -> Text(person.name) }
}
}class MyViewModel : ViewModel() {
private val _people = MutableStateFlow<List<Person>>(loadPeople())
private val _selectedIds = MutableStateFlow<Set<Int>>(emptySet())
private val _selectionModeEnabled = MutableStateFlow(false)
val tableData: StateFlow<PersonTableData> = combine(
_people,
_selectedIds,
_selectionModeEnabled,
) { people, selected, enabled ->
PersonTableData(
displayedPeople = people,
selectedIds = selected,
selectionModeEnabled = enabled,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PersonTableData())
fun setSelectionMode(enabled: Boolean) {
_selectionModeEnabled.value = enabled
if (!enabled) _selectedIds.value = emptySet()
}
fun toggleSelection(personId: Int) {
_selectedIds.update { current ->
if (personId in current) current - personId else current + personId
}
}
fun toggleSelectAll() {
val displayedIds = _people.value.map { it.id }.toSet()
_selectedIds.update { current ->
if (displayedIds.all { it in current }) {
current - displayedIds // Deselect all
} else {
current + displayedIds // Select all
}
}
}
fun deleteSelected() {
val idsToDelete = _selectedIds.value
_people.update { it.filter { person -> person.id !in idsToDelete } }
_selectedIds.value = emptySet()
}
}@Composable
fun PeopleScreen(viewModel: MyViewModel) {
val tableData by viewModel.tableData.collectAsState()
Box(modifier = Modifier.fillMaxSize()) {
Table(
itemsCount = tableData.displayedPeople.size,
itemAt = { tableData.displayedPeople.getOrNull(it) },
state = state,
columns = columns,
tableData = tableData,
)
// Floating action bar shown when items are selected
if (tableData.selectedIds.isNotEmpty()) {
Surface(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.primaryContainer,
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("${tableData.selectedIds.size} selected")
Button(onClick = { viewModel.deleteSelected() }) {
Text("Delete")
}
}
}
}
}
}- Reactive UI: Checkbox state updates instantly when
tableData.selectedIdschanges. - Centralized state: Selection logic lives in ViewModel, making it testable and reusable.
- Flexible visibility: Show/hide the checkbox column by controlling width via
state.setColumnWidths(). - Bulk operations: Easy to implement delete, export, or other actions on selected items.
- Header integration: Tri-state checkbox in header provides intuitive select-all UX.
- Dynamic height: set
rowHeightMode = RowHeightMode.Dynamic. Use per‑columnrowHeight(min, max)to hint bounds. - Auto‑width: call
autoWidth(max?)in column builder. The table measures header + first batch of rows and applies widths once per phase. Double‑click the header resizer to snap a column to its measured max content width. - Alternatively, use
state.recalculateAutoWidths()to manually trigger width recalculation based on current content measurements (useful for deferred/paginated data loading scenarios).
By default, the table enables drag-to-scroll functionality, allowing users to pan the table content by dragging with mouse or touch gestures. While this works well on mobile devices, it may not be ideal for desktop environments where traditional scrollbars and mouse wheel navigation are preferred.
To disable drag-to-scroll and use standard scrollbars instead:
val state = rememberTableState(
columns = columns.map { it.key },
settings = TableSettings(
enableDragToScroll = false, // Disable drag-to-scroll
// ... other settings
)
)When enableDragToScroll = false:
- Mouse dragging will not scroll the table
- Horizontal and vertical scrollbars will be available
- Mouse wheel and trackpad gestures will work normally
- Better compatibility with cell selection and text selection workflows
Customize sort/filter icons:
val icons = TableHeaderDefaults.icons(
sortAsc = MyUp,
sortDesc = MyDown,
sortNeutral = MySort,
filterActive = MyFilterFilled,
filterInactive = MyFilterOutline
)
Table(
itemsCount = items.size,
itemAt = { index -> items[index] },
state = state,
columns = columns,
icons = icons
)- Build
TableCustomizationfrom rules usingrememberCustomization(rules, matches = ...). Row‑wide rules havecolumns = emptyList(); cell‑specific rules list field keys incolumns. - Use
FormatDialog(...)to let users create/edit rules.
Minimal example:
data class Person(val name: String, val age: Int, val rating: Int)
enum class PersonField { Name, Age, Rating }
// Rules
val rules = remember {
val ratingFilter: Map<PersonField, TableFilterState<*>> =
mapOf(
PersonField.Rating to TableFilterState(
constraint = FilterConstraint.GTE,
values = listOf(4),
),
)
val ratingRule =
TableFormatRule<PersonField, Map<PersonField, TableFilterState<*>>>(
id = 1L,
enabled = true,
base = false,
columns = listOf(PersonField.Rating),
cellStyle = TableCellStyleConfig(
contentColor = 0xFFFFD700.toInt(), // Gold
),
filter = ratingFilter,
)
listOf(ratingRule)
}
// Matching logic (app‑specific)
val customization = rememberCustomization<Person, PersonField, Person>(
rules = rules,
matches = { person, ruleFilters ->
for ((column, stateAny) in ruleFilters) {
when (column) {
PersonField.Rating -> {
val value = person.rating
val st = stateAny as TableFilterState<Int>
val constraint = st.constraint ?: continue
when (constraint) {
FilterConstraint.GT -> value > (st.values?.getOrNull(0) ?: value)
FilterConstraint.GTE -> value >= (st.values?.getOrNull(0) ?: value)
FilterConstraint.LT -> value < (st.values?.getOrNull(0) ?: value)
FilterConstraint.LTE -> value <= (st.values?.getOrNull(0) ?: value)
FilterConstraint.EQUALS -> value == (st.values?.getOrNull(0) ?: value)
FilterConstraint.NOT_EQUALS -> value != (st.values?.getOrNull(0) ?: value)
FilterConstraint.BETWEEN -> {
val from = st.values?.getOrNull(0) ?: value
val to = st.values?.getOrNull(1) ?: value
from <= value && value <= to
}
else -> true
}
}
else -> true
}
}
}
)
Table(
itemsCount = items.size,
itemAt = { index -> items[index] },
state = state,
columns = columns,
customization = customization
)
// Optional dialog
FormatDialog(
showDialog = show,
rules = rules,
onRulesChanged = { /* persist */ },
getNewRule = { id -> TableFormatRule.new<PersonField, Person>(id, Person("", 0)) },
getTitle = { it.name },
filters = { rule, onApply -> emptyList() }, // build `FormatFilterData` list for your fields
entries = PersonField.values().toList(),
key = Unit,
strings = DefaultStrings,
onDismissRequest = { show = false }
)Public API highlights:
rememberCustomization<T, C, FILTER>(rules, matches = ...) : TableCustomization<T, C>.TableFormatRule<FIELD, FILTER>withcolumns: List<FIELD>,cellStyle: TableCellStyleConfig,filter: FILTER.FormatDialog(...)andFormatDialogSettingsfor UX tweaks.FormatFilterData<E>to describe per‑field filter controls in the dialog.
- Android, JVM (Desktop), JS (Web), iOS (KMP source sets present; targets enabled via project conventions).
This project uses the following open source libraries:
| Library | License | Description |
|---|---|---|
| Reorderable | Apache License 2.0 | Drag and drop functionality for reordering items in Compose |
| Paging for KMP | Apache License 2.0 | Kotlin Multiplatform paging library |
| ColorPicker Compose | Apache License 2.0 | Color picker component for Jetpack Compose |
| Kermit | Apache License 2.0 | Kotlin Multiplatform logging library |
All third-party libraries are used in compliance with their respective licenses. For detailed license information, see the individual library repositories linked above.
Licensed under the Apache License, Version 2.0. See LICENSE for details.