
Provides a data table with Material 3 design, supporting core functionalities like sorting, filtering, column customization, conditional formatting, and paging integration. Features include drag-and-drop column reordering, multi-platform support, and extensive customization options.
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).
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 for PagingData (ua.wwind.paging).TableHeaderDefaults.icons).FilterPanel.embedded flag and rowEmbedded slot for building master–detail layouts inside
a single table.TableCustomization (background/content color, elevation, borders, typography,
alignment). Outer table border is configurable via border parameter (custom stroke or disabled entirely).StringProvider (default DefaultStrings).Add repository (usually mavenCentral) and include the modules you need:
dependencies {
implementation("ua.wwind.table-kmp:table-core:1.7.13")
// optional
implementation("ua.wwind.table-kmp:table-format:1.7.13")
implementation("ua.wwind.table-kmp:table-paging:1.7.13")
}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.13 | 2.3.10 | 1.10.1 |
| 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.
TableSettings(editingEnabled = true).EditableTable<T, C, E> when you need editing support.E represents table data (shared state) accessible in headers,
footers, and edit cells. This allows passing validation errors, aggregated values, or any other table-wide state.editableTableColumns<T, C, E> { ... } and per‑cell editCell.onRowEditStart, onRowEditComplete, onEditCancelled.For text editing inside table cells there is a dedicated composable TableCellTextField:
syncEditCellFocus() on its Modifier.
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.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:
TableCellTextField directly: this is the recommended and simplest way. It already calls
syncEditCellFocus() on its modifier, so the cell participates in the table focus chain automatically.@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:
onComplete in editCell moves to the next editable cell and
eventually triggers onRowEditComplete.By either using TableCellTextField or reusing syncEditCellFocus() in your own composables, custom edit UIs stay
consistent with the default table editing behavior.
Runtime behavior:
editCell content.onComplete() and move to the next editable column.onRowEditComplete is invoked; returning false keeps the row in edit mode.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:
true (default), footer stays visible at the bottom of the table viewport, similar to a sticky
header. When false, footer scrolls with table content.TableDimensions.footerHeight.TableColors.footerContainerColor and TableColors.footerContentColor.The footer:
@Composable
fun PeoplePagingTable(paging: PagingData<Person>) {
Table(
items = paging,
state = state,
columns = columns,
)
}There is also LazyListScope.handleLoadState(...) to render loading/empty states.
TableCustomization from rules via rememberCustomization(rules, matches = ...). Row‑wide rules have
columns = emptyList(); cell‑specific rules list field keys in columns.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.).
Table<T, C>: renders header and virtualized rows for read-only tables (tableData = Unit).
itemsCount, itemAt(index), state: TableState<C>, columns: List<ColumnSpec<T, C, Unit>>.placeholderRow().onRowClick, onRowLongClick, contextMenu(item, pos, dismiss).customization, colors = TableDefaults.colors(), icons = TableHeaderDefaults.icons(), strings,
shape, border (outer border; null = theme default, TableDefaults.NoBorder = no border).verticalState, horizontalState.embedded flag and rowEmbedded slot 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.Table<T, C, E>: overload that accepts custom table data for headers, footers, and edit cells.
tableData: E - shared state accessible in headers, footers, custom filters, and edit
cells.EditableTable<T, C, E>: renders header and virtualized rows with editing support.
tableData: E, onRowEditStart, onRowEditComplete, onEditCancelled.ColumnSpec<T, C, E> with E matching the tableData type.tableColumns<T, C, E> { ... } produces List<ColumnSpec<T, C, E>> for read-only tables.editableTableColumns<T, C, E> { ... } produces List<ColumnSpec<T, C, E>> for editable tables.cell { item, tableData -> ... } for regular cell content with access to table data (use _ if table
data is not needed).header("Text") or header(tableData) { ... }; optional title { "Name" } for active filter chips.footer(tableData) { ... } for custom footer cell content with access to table data.editCell { item, tableData, onComplete -> ... } for custom edit UI.sortable(), headerClickToSort(Boolean).filter(TableFilterType.*).width(min, pref), autoWidth(max), resizable(Boolean), align(Alignment.Horizontal).rowHeight(min, max) used when rowHeightMode = Dynamic.
headerDecorations(Boolean) to hide built‑ins when fully customizing header.headerDecorations = true (default), the table places sort and filter icons automatically.headerDecorations(false) and use helpers inside header { ... }: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())
}rememberTableState(columns, initialSort?, initialOrder?, initialWidths?, settings?, dimensions?).
state.setSort(column, order?); current state.sort.state.groupBy(column) to enable grouping; state.groupBy(null) to disable.state.setColumnOrder(order), state.resizeColumn(column, Set/Reset),
state.setColumnWidths(map).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.state.setFilter(column, TableFilterState(...)); current per‑column state.filters.state.toggleSelect(index), state.toggleCheck(index), state.toggleCheckAll(count),
state.selectCell(row, column).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 in SelectionContainer to allow text selection; defaults to false),
showVerticalDividers (show/hide vertical dividers between columns; defaults to true),
showRowDividers (show/hide horizontal dividers between rows; defaults to true),
showHeaderDivider (show/hide horizontal divider below header; defaults to true),
showFastFiltersDivider (show/hide horizontal divider below fast filters row; defaults to true).TableDimensions: defaultColumnWidth, defaultRowHeight, footerHeight, checkBoxColumnWidth,
verticalDividerThickness, verticalDividerPaddingHorizontal.TableColors: via TableDefaults.colors(...).rangeOptions.getTitle(BooleanType).kotlinx.datetime.LocalDate).options: List<T> and getTitle(T).CustomFilterRenderer<T, E> for main panel and
optional fast filter (both receive tableData: E parameter), and CustomFilterStateProvider<T> for chip text.
Supports data visualizations of any complexity, including dynamic histograms and statistics based on current table
data.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:
settings.showFastFilters = true and at least
one visible column has a filter configured (not null or DisabledTableFilter).state.filters, changes in one immediately
reflect in the other.TextTableFilter → CONTAINSNumberTableFilter → EQUALSBooleanTableFilter → EQUALS (tri-state checkbox)DateTableFilter → EQUALS (date picker)EnumTableFilter → EQUALS (dropdown)CustomTableFilter → fully custom (implement RenderFastFilter or leave empty)settings.autoFilterDebounce).Fast filters are ideal for quick data exploration and filtering without opening the full filter panel dialog.
SelectionMode.None (default), Single, Multiple.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:
SelectionMode
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")
}
}
}
}
}
}tableData.selectedIds changes.state.setColumnWidths().rowHeightMode = RowHeightMode.Dynamic. Use per‑column rowHeight(min, max) to hint bounds.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.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:
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
)TableCustomization from rules using rememberCustomization(rules, matches = ...). Row‑wide rules have
columns = emptyList(); cell‑specific rules list field keys in columns.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> with columns: List<FIELD>, cellStyle: TableCellStyleConfig, filter: FILTER.FormatDialog(...) and FormatDialogSettings for UX tweaks.FormatFilterData<E> to describe per‑field filter controls in the dialog.FilterConstraint.isNullCheck() extension function to check for IS_NULL/IS_NOT_NULL constraints.TableFilterState.isActive() extension function to determine if a filter is active.VerticalScrollbarRenderer and VerticalScrollbarState for custom scrollbar rendering in formatting dialogs.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.
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).
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 for PagingData (ua.wwind.paging).TableHeaderDefaults.icons).FilterPanel.embedded flag and rowEmbedded slot for building master–detail layouts inside
a single table.TableCustomization (background/content color, elevation, borders, typography,
alignment). Outer table border is configurable via border parameter (custom stroke or disabled entirely).StringProvider (default DefaultStrings).Add repository (usually mavenCentral) and include the modules you need:
dependencies {
implementation("ua.wwind.table-kmp:table-core:1.7.13")
// optional
implementation("ua.wwind.table-kmp:table-format:1.7.13")
implementation("ua.wwind.table-kmp:table-paging:1.7.13")
}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.13 | 2.3.10 | 1.10.1 |
| 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.
TableSettings(editingEnabled = true).EditableTable<T, C, E> when you need editing support.E represents table data (shared state) accessible in headers,
footers, and edit cells. This allows passing validation errors, aggregated values, or any other table-wide state.editableTableColumns<T, C, E> { ... } and per‑cell editCell.onRowEditStart, onRowEditComplete, onEditCancelled.For text editing inside table cells there is a dedicated composable TableCellTextField:
syncEditCellFocus() on its Modifier.
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.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:
TableCellTextField directly: this is the recommended and simplest way. It already calls
syncEditCellFocus() on its modifier, so the cell participates in the table focus chain automatically.@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:
onComplete in editCell moves to the next editable cell and
eventually triggers onRowEditComplete.By either using TableCellTextField or reusing syncEditCellFocus() in your own composables, custom edit UIs stay
consistent with the default table editing behavior.
Runtime behavior:
editCell content.onComplete() and move to the next editable column.onRowEditComplete is invoked; returning false keeps the row in edit mode.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:
true (default), footer stays visible at the bottom of the table viewport, similar to a sticky
header. When false, footer scrolls with table content.TableDimensions.footerHeight.TableColors.footerContainerColor and TableColors.footerContentColor.The footer:
@Composable
fun PeoplePagingTable(paging: PagingData<Person>) {
Table(
items = paging,
state = state,
columns = columns,
)
}There is also LazyListScope.handleLoadState(...) to render loading/empty states.
TableCustomization from rules via rememberCustomization(rules, matches = ...). Row‑wide rules have
columns = emptyList(); cell‑specific rules list field keys in columns.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.).
Table<T, C>: renders header and virtualized rows for read-only tables (tableData = Unit).
itemsCount, itemAt(index), state: TableState<C>, columns: List<ColumnSpec<T, C, Unit>>.placeholderRow().onRowClick, onRowLongClick, contextMenu(item, pos, dismiss).customization, colors = TableDefaults.colors(), icons = TableHeaderDefaults.icons(), strings,
shape, border (outer border; null = theme default, TableDefaults.NoBorder = no border).verticalState, horizontalState.embedded flag and rowEmbedded slot 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.Table<T, C, E>: overload that accepts custom table data for headers, footers, and edit cells.
tableData: E - shared state accessible in headers, footers, custom filters, and edit
cells.EditableTable<T, C, E>: renders header and virtualized rows with editing support.
tableData: E, onRowEditStart, onRowEditComplete, onEditCancelled.ColumnSpec<T, C, E> with E matching the tableData type.tableColumns<T, C, E> { ... } produces List<ColumnSpec<T, C, E>> for read-only tables.editableTableColumns<T, C, E> { ... } produces List<ColumnSpec<T, C, E>> for editable tables.cell { item, tableData -> ... } for regular cell content with access to table data (use _ if table
data is not needed).header("Text") or header(tableData) { ... }; optional title { "Name" } for active filter chips.footer(tableData) { ... } for custom footer cell content with access to table data.editCell { item, tableData, onComplete -> ... } for custom edit UI.sortable(), headerClickToSort(Boolean).filter(TableFilterType.*).width(min, pref), autoWidth(max), resizable(Boolean), align(Alignment.Horizontal).rowHeight(min, max) used when rowHeightMode = Dynamic.
headerDecorations(Boolean) to hide built‑ins when fully customizing header.headerDecorations = true (default), the table places sort and filter icons automatically.headerDecorations(false) and use helpers inside header { ... }: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())
}rememberTableState(columns, initialSort?, initialOrder?, initialWidths?, settings?, dimensions?).
state.setSort(column, order?); current state.sort.state.groupBy(column) to enable grouping; state.groupBy(null) to disable.state.setColumnOrder(order), state.resizeColumn(column, Set/Reset),
state.setColumnWidths(map).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.state.setFilter(column, TableFilterState(...)); current per‑column state.filters.state.toggleSelect(index), state.toggleCheck(index), state.toggleCheckAll(count),
state.selectCell(row, column).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 in SelectionContainer to allow text selection; defaults to false),
showVerticalDividers (show/hide vertical dividers between columns; defaults to true),
showRowDividers (show/hide horizontal dividers between rows; defaults to true),
showHeaderDivider (show/hide horizontal divider below header; defaults to true),
showFastFiltersDivider (show/hide horizontal divider below fast filters row; defaults to true).TableDimensions: defaultColumnWidth, defaultRowHeight, footerHeight, checkBoxColumnWidth,
verticalDividerThickness, verticalDividerPaddingHorizontal.TableColors: via TableDefaults.colors(...).rangeOptions.getTitle(BooleanType).kotlinx.datetime.LocalDate).options: List<T> and getTitle(T).CustomFilterRenderer<T, E> for main panel and
optional fast filter (both receive tableData: E parameter), and CustomFilterStateProvider<T> for chip text.
Supports data visualizations of any complexity, including dynamic histograms and statistics based on current table
data.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:
settings.showFastFilters = true and at least
one visible column has a filter configured (not null or DisabledTableFilter).state.filters, changes in one immediately
reflect in the other.TextTableFilter → CONTAINSNumberTableFilter → EQUALSBooleanTableFilter → EQUALS (tri-state checkbox)DateTableFilter → EQUALS (date picker)EnumTableFilter → EQUALS (dropdown)CustomTableFilter → fully custom (implement RenderFastFilter or leave empty)settings.autoFilterDebounce).Fast filters are ideal for quick data exploration and filtering without opening the full filter panel dialog.
SelectionMode.None (default), Single, Multiple.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:
SelectionMode
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")
}
}
}
}
}
}tableData.selectedIds changes.state.setColumnWidths().rowHeightMode = RowHeightMode.Dynamic. Use per‑column rowHeight(min, max) to hint bounds.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.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:
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
)TableCustomization from rules using rememberCustomization(rules, matches = ...). Row‑wide rules have
columns = emptyList(); cell‑specific rules list field keys in columns.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> with columns: List<FIELD>, cellStyle: TableCellStyleConfig, filter: FILTER.FormatDialog(...) and FormatDialogSettings for UX tweaks.FormatFilterData<E> to describe per‑field filter controls in the dialog.FilterConstraint.isNullCheck() extension function to check for IS_NULL/IS_NOT_NULL constraints.TableFilterState.isActive() extension function to determine if a filter is active.VerticalScrollbarRenderer and VerticalScrollbarState for custom scrollbar rendering in formatting dialogs.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.