Data Table
Rows of records in columns: a header that orders them, boxes that choose them, rows that open, columns that stay in place while the others scroll, and a list of records where there is no room for a table.
import com.pantrixui.components.data.datatable.PantrixDataTable
- Columns. A column is described with a
DataTableColumn: its key (key), its header (header) and what a row shows in that column (cell). A column of plain text is written shortly withDataTableColumn.text: a long text is cut with an ellipsis. A cell can be any content: a badge, a button, an avatar. - Width. A column given a
widthis that wide. Those given none share what is left by theirweight, and are no narrower thanminWidthor, where that is not given either, the style'scolumnMinWidth.alignmentsets the header and the values at one side of the column: numbers stand at its end, so that their digits line up. - Scrolling sideways. Where the columns do not fit the table even at their least widths, the table scrolls sideways: by a drag of a finger, by the wheel, by the bar under the rows where there is a pointer, and by the keyboard: the cell that takes the focus is brought into view.
- Pinned columns.
pin = DataTablePin.Startkeeps a column at the table's start,DataTablePin.Endat its end: the others scroll under them. Where more than half of a table that scrolls would stay in place, the pinned columns scroll with the others, and only the boxes stay. - Sorting. The header of a column with
sortable = true, or one given acomparator, is a button. The first press orders smallest first, the second largest first, and the third takes the order away. An arrow that points up means the smallest comes first.multiSort = trueadds the pressed column to the order: the rows are put in the first column's order, and the next settles the ones it holds equal; a header shows its column's place in the order. Ordering the rows is the caller's work:sort.comparator(columns)makes one comparator out of the columns' own. A column of text takes atextOrderto be ordered by the language's alphabet:rememberTextOrder()gives the one of the theme's language. - Selection. A table given
onSelectionChangehas a box in every row; the keys of the chosen rows are given withselected. The box in the header chooses every row that is shown or lets them all go, and leaves what was chosen on other pages alone; it shows a dash while some are chosen.DataTableSelection.Singleputs radio buttons in the boxes' place: choosing a row lets the other go. - Rows that open. A table given
rowDetailandonExpandedChangehas a chevron in every row: a press opens the row's detail under it, as wide as the table. The keys of the open rows are given withexpanded. The chevron stands at the row's end and stays there while the columns scroll;DataTableStyle(expander = DataTableExpander.Start)puts it at the start. - Rows that are pressed.
onRowClickmakes every row a button as wide as the table. - Closed rows.
rowEnabledcloses a row: it is drawn fainter, cannot be chosen or pressed, and the box in the header neither chooses it nor lets it go. Its chevron opens it all the same; closing the buttons in its cells is the caller's work. - Tones.
rowTonelays a row on the soft fill of one of the theme's roles and turns its text to the colour paired with that fill; a column'stonepaints its header. What the colour says should be written in the row in words too. - Height. A table told no height is as tall as its rows and builds them all: that is for a page of rows, in a page that scrolls, and its header scrolls with the page. A table told a height keeps its header in place, scrolls its rows under it and builds only the ones in view: it holds thousands of rows. A table whose header is to stay in place is told a height.
- State. Where the rows and the columns are scrolled to and the widths the columns were dragged to are kept in a
DataTableState. A screen that holds it itself, withrememberDataTableState(), reads the widths, keeps them, resets them (columnWidths) and scrolls to a column (animateScrollToColumn). - Narrow. A table of
DataTableLayout.Autois a list of records where its own width is narrower than the style'scompactWidth(600dp): each row a block, each value with its column's name before it. The boxes, the chevrons and the order stay.showInList = falsekeeps a column out of the list;listContentdraws a record its own way. - Loading and empty.
loading = trueputs skeleton rows in the rows' place, in the table's columns. A table without rows shows the theme's words, "No rows", or what is given withempty. - Toolbar and footer.
toolbarstands over the header andfooterunder the rows, inside the table's edge: a title and what is done with the chosen rows; aPantrixPagination. - Variants.
DataTableVariant.Plaindraws a line between the rows,Borderedan edge around the table as well, andGrida line between the columns too.striped = truelays every other row on a faint fill. - Columns whose width changes. The end of the header of a column with
resizable = trueis dragged with a pointer. The keyboard comes there with Tab after the column's name, and the arrow keys along the row make the column wider or narrower; a screen reader is told the width as a value that can be changed. On screens used with a finger there is no handle to drag. - Choosing the columns. Which columns are shown, and in which order, is the
columnslist itself: a screen that hides a column or moves one gives the table another list. - The headers' type.
headerTextStyleis the type the headers are written in. The headers are written as they are given: a table that wants small capitals set apart gives the type and writes its headers in capitals. - Colours.
DataTableColorspaints the table's fill, its text, the header's band and its text, the stripe, the fill of a chosen row and the lines one by one; what is not given is the theme's. - Automation. A table given a
tagKeyisDataTable_<tagKey>, and its parts are named inside it:Header_<column>,Row_<row>,Cell_<row>/<column>,Select_<row>,Toggle_<row>,Detail_<row>. Presses are reported as aDataTableevent:sort,select,selectAll,expand,collapse,open.
Examples
Orders
A table of orders as an app shows one: the customer with an avatar and an email, the order's status as a tag, and the total at the end of its column, so that the digits line up. Each column says how much of the width it takes: a width of its own, or a share of what the others leave (weight), no narrower than minWidth.
The table stays a table at every width, a phone's too, and scrolls sideways where its columns do not fit (layout = DataTableLayout.Table). Left to choose by its width, a table narrower than 600dp becomes a list of records: the last example shows that.
PantrixDataTable(
rows = ORDERS,
columns = remember { orderColumns() },
rowKey = { it.id },
variant = DataTableVariant.Bordered,
layout = DataTableLayout.Table,
tagKey = "orders"
)
private fun orderColumns(): List<DataTableColumn<Order>> = listOf(
DataTableColumn.text(key = "id", header = "Order", width = 96.dp) { "#${it.id}" },
DataTableColumn(key = "customer", header = "Customer", weight = 2f, minWidth = 240.dp) { order ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
PantrixAvatar(contentDescription = null, initials = order.initials, tone = order.tone, size = 32.dp)
Column {
PantrixText(text = order.customer, style = PantrixTheme.typography.label)
PantrixText(
text = order.email,
style = PantrixTheme.typography.footnote,
color = PantrixTheme.colors.textSecondary
)
}
}
},
DataTableColumn.text(key = "date", header = "Date", width = 128.dp) { it.date },
DataTableColumn(key = "status", header = "Status", width = 128.dp) { order ->
PantrixTag(text = order.status.label, tone = order.status.tone, dot = true)
},
DataTableColumn.text(key = "total", header = "Total", width = 112.dp, alignment = Alignment.End) {
money(it.total)
}
)
private enum class Status(val label: String, val tone: TagTone) {
Paid("Paid", TagTone.Success),
Pending("Pending", TagTone.Warning),
Refunded("Refunded", TagTone.Neutral),
Failed("Failed", TagTone.Danger)
}
private class Order(
val id: String,
val customer: String,
val email: String,
val tone: AvatarTone,
val date: String,
val status: Status,
val total: Int
) {
val initials: String = customer.split(" ").joinToString("") { it.take(1) }
}
private val ORDERS = listOf(
Order("3210", "Olivia Martin", "olivia.martin@mail.com", AvatarTone.Primary, "Oct 28, 2026", Status.Paid, 1999),
Order("3209", "Jackson Lee", "jackson.lee@mail.com", AvatarTone.Info, "Oct 28, 2026", Status.Pending, 39),
Order("3208", "Isabella Nguyen", "isabella@mail.com", AvatarTone.Success, "Oct 27, 2026", Status.Paid, 299),
Order("3207", "William Kim", "will@mail.com", AvatarTone.Warning, "Oct 27, 2026", Status.Refunded, 99),
Order("3206", "Sofia Davis", "sofia.davis@mail.com", AvatarTone.Danger, "Oct 26, 2026", Status.Failed, 450),
Order("3205", "Liam Johnson", "liam.johnson@mail.com", AvatarTone.Neutral, "Oct 25, 2026", Status.Paid, 2350)
)
private fun money(amount: Int): String = "$" + amount.toString().reversed().chunked(3).joinToString(",").reversed()
Sorting
A column with sortable = true has a header that is a button. Press Price: the products go from the cheapest up, and an arrow beside the header says so; press it again and they go from the dearest down; a third press takes the order away. The order is the screen's (sort): the table says which order is asked for (onSortChange), and sort.comparator(columns) puts the rows in it.
The names are ordered as the reader's language orders its letters (rememberTextOrder), and the stock and the price as numbers, each by a comparator of its own. With "Sort by several columns" on, a press adds its column to the order: press Category, then Price, and the products of a category go from the cheapest up.
var sort by remember { mutableStateOf(listOf(DataTableSort("price", descending = true))) }
var several by remember { mutableStateOf(false) }
val textOrder = rememberTextOrder()
val columns = remember(textOrder) { productColumns(textOrder) }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
PantrixSwitchButton(
checked = several,
onCheckedChange = { several = it },
label = "Sort by several columns",
tagKey = "several"
)
PantrixDataTable(
rows = remember(sort, columns) { PRODUCTS.sortedWith(sort.comparator(columns)) },
columns = columns,
rowKey = { it.sku },
sort = sort,
onSortChange = { sort = it },
multiSort = several,
variant = DataTableVariant.Bordered,
layout = DataTableLayout.Table,
tagKey = "products"
)
}
private fun productColumns(textOrder: Comparator<String>): List<DataTableColumn<Product>> = listOf(
DataTableColumn.text(key = "name", header = "Product", weight = 2f, sortable = true, textOrder = textOrder) {
it.name
},
DataTableColumn.text(key = "category", header = "Category", sortable = true, textOrder = textOrder) {
it.category
},
DataTableColumn(
key = "stock",
header = "Stock",
width = 136.dp,
alignment = Alignment.End,
sortable = true,
comparator = compareBy<Product> { it.stock }
) { product ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
if (product.stock < LOW_STOCK) PantrixTag(text = "Low", tone = TagTone.Warning)
PantrixText(text = "${product.stock}")
}
},
DataTableColumn.text(
key = "price",
header = "Price",
width = 112.dp,
alignment = Alignment.End,
sortable = true,
comparator = compareBy<Product> { it.price }
) { money(it.price) }
)
private class Product(
val sku: String,
val name: String,
val category: String,
val stock: Int,
val price: Int
)
private val PRODUCTS = listOf(
Product("LMP-01", "Aurora desk lamp", "Lighting", 42, 89),
Product("CHR-07", "Nimbus office chair", "Furniture", 7, 349),
Product("MSE-12", "Orbit wireless mouse", "Accessories", 128, 39),
Product("MUG-03", "Ember ceramic mug", "Kitchen", 3, 18),
Product("DSK-02", "Atlas standing desk", "Furniture", 15, 629),
Product("SPK-05", "Echo speaker", "Audio", 64, 129),
Product("HUB-09", "Ion USB-C hub", "Accessories", 9, 59),
Product("LMP-04", "Lumen floor lamp", "Lighting", 21, 149)
)
private const val LOW_STOCK = 10
private fun money(amount: Int): String = "$" + amount.toString().reversed().chunked(3).joinToString(",").reversed()
Choosing rows
Given selected and onSelectionChange, the table puts a box at the start of each row, and one in the header that chooses every row at once. Which rows are chosen is the screen's: here the toolbar over the header (toolbar) counts them, and removes them from the team.
rowLabel names each row to a screen reader: its box says which member it chooses. Remove a few, then bring them back with "Restore all".
var members by remember { mutableStateOf(MEMBERS) }
var chosen by remember { mutableStateOf(emptySet<String>()) }
PantrixDataTable(
rows = members,
columns = remember { memberColumns() },
rowKey = { it.email },
rowLabel = { it.name },
selected = chosen,
onSelectionChange = { chosen = it },
toolbar = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
PantrixText(
text = if (chosen.isEmpty()) {
"${members.size} members"
} else {
PantrixTheme.strings.selectedCount(chosen.size)
},
style = PantrixTheme.typography.label
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PantrixButton(
text = "Restore all",
onClick = {
members = MEMBERS
chosen = emptySet()
},
variant = ButtonVariant.Text,
size = ButtonSize.Small,
enabled = members.size < MEMBERS.size,
tagKey = "restore"
)
PantrixButton(
text = "Remove",
onClick = {
members = members.filterNot { it.email in chosen }
chosen = emptySet()
},
variant = ButtonVariant.Destructive,
size = ButtonSize.Small,
enabled = chosen.isNotEmpty(),
tagKey = "remove"
)
}
}
},
variant = DataTableVariant.Bordered,
layout = DataTableLayout.Table,
tagKey = "members"
)
private fun memberColumns(): List<DataTableColumn<Member>> = listOf(
DataTableColumn(key = "member", header = "Member", weight = 2f, minWidth = 240.dp) { member ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
PantrixAvatar(contentDescription = null, initials = member.initials, size = 32.dp)
Column {
PantrixText(text = member.name, style = PantrixTheme.typography.label)
PantrixText(
text = member.email,
style = PantrixTheme.typography.footnote,
color = PantrixTheme.colors.textSecondary
)
}
}
},
DataTableColumn(key = "role", header = "Role", width = 120.dp) { member ->
PantrixTag(text = member.role, tone = if (member.role == "Owner") TagTone.Primary else TagTone.Neutral)
},
DataTableColumn.text(key = "team", header = "Team", width = 128.dp) { it.team },
DataTableColumn.text(key = "active", header = "Last active", width = 128.dp) { it.lastActive }
)
private class Member(
val name: String,
val email: String,
val role: String,
val team: String,
val lastActive: String
) {
val initials: String = name.split(" ").joinToString("") { it.take(1) }
}
private val MEMBERS = listOf(
Member("Ava Thompson", "ava@acme.io", "Owner", "Leadership", "Now"),
Member("Noah Garcia", "noah@acme.io", "Admin", "Engineering", "5 min ago"),
Member("Mia Robinson", "mia@acme.io", "Editor", "Design", "1 hour ago"),
Member("Ethan Clark", "ethan@acme.io", "Editor", "Engineering", "Yesterday"),
Member("Harper Lewis", "harper@acme.io", "Viewer", "Marketing", "3 days ago"),
Member("Lucas Walker", "lucas@acme.io", "Viewer", "Sales", "2 weeks ago")
)
Rows that open
Given rowDetail and onExpandedChange, each row starts with a chevron: press it, and what the row holds opens under it, as wide as the table. Here an invoice opens to its lines, its total and a button that downloads it. Which rows are open is the screen's (expanded), so the screen opens the first one itself.
var open by remember { mutableStateOf(setOf("INV-2041")) }
PantrixDataTable(
rows = INVOICES,
columns = remember { invoiceColumns() },
rowKey = { it.number },
rowLabel = { it.number },
expanded = open,
onExpandedChange = { open = it },
rowDetail = { invoice -> InvoiceLines(invoice) },
variant = DataTableVariant.Bordered,
layout = DataTableLayout.Table,
tagKey = "invoices"
)
private fun invoiceColumns(): List<DataTableColumn<Invoice>> = listOf(
DataTableColumn.text(key = "number", header = "Invoice", width = 112.dp) { it.number },
DataTableColumn.text(key = "client", header = "Client", weight = 2f, minWidth = 160.dp) { it.client },
DataTableColumn.text(key = "due", header = "Due", width = 120.dp) { it.due },
DataTableColumn(key = "status", header = "Status", width = 112.dp) { invoice ->
PantrixTag(text = invoice.status, tone = if (invoice.status == "Overdue") TagTone.Danger else TagTone.Success)
},
DataTableColumn.text(key = "amount", header = "Amount", width = 112.dp, alignment = Alignment.End) {
money(it.lines.sumOf { line -> line.amount })
}
)
@Composable
private fun InvoiceLines(invoice: Invoice) {
Column(Modifier.widthIn(max = 480.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
invoice.lines.forEach { line ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
PantrixText(text = line.item, color = PantrixTheme.colors.textSecondary)
PantrixText(text = money(line.amount))
}
}
PantrixHorizontalDivider()
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
PantrixText(text = "Total", style = PantrixTheme.typography.label.copy(fontWeight = FontWeight.SemiBold))
PantrixText(
text = money(invoice.lines.sumOf { it.amount }),
style = PantrixTheme.typography.label.copy(fontWeight = FontWeight.SemiBold)
)
}
PantrixButton(
text = "Download PDF",
onClick = {},
variant = ButtonVariant.Outline,
size = ButtonSize.Small,
leadingIcon = { PantrixIcon(imageVector = PantrixIcons.Download, contentDescription = null) },
tagKey = "download_${invoice.number}"
)
}
}
private class Line(val item: String, val amount: Int)
private class Invoice(
val number: String,
val client: String,
val due: String,
val status: String,
val lines: List<Line>
)
private val INVOICES = listOf(
Invoice(
"INV-2041",
"Northwind Traders",
"Nov 12, 2026",
"Paid",
listOf(Line("Design system audit", 4200), Line("Component workshop, 2 days", 3600), Line("Travel", 480))
),
Invoice("INV-2040", "Globex", "Nov 5, 2026", "Paid", listOf(Line("Annual licence, 25 seats", 7500))),
Invoice(
"INV-2039",
"Initech",
"Oct 20, 2026",
"Overdue",
listOf(Line("Accessibility review", 2800), Line("Fixes to 14 screens", 3150))
),
Invoice("INV-2038", "Umbrella Health", "Oct 14, 2026", "Paid", listOf(Line("Support plan, October", 1200)))
)
private fun money(amount: Int): String = "$" + amount.toString().reversed().chunked(3).joinToString(",").reversed()
A wide report
Fourteen columns are wider than any page: the table scrolls sideways, by a drag, a wheel, the bar under the rows, or the keyboard. The region is pinned to the start and the total to the end (pin), so that each month is read against them while the others pass under. In a Grid table a line runs between the columns as well, for a report the eye reads down as much as along.
The screen holds the table's state (rememberDataTableState): "Go to December" scrolls the months until its column is in view (animateScrollToColumn).
val state = rememberDataTableState()
val scope = rememberCoroutineScope()
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
PantrixButton(
text = "Go to December",
onClick = { scope.launch { state.animateScrollToColumn("m12") } },
variant = ButtonVariant.Outline,
size = ButtonSize.Small,
tagKey = "to_december"
)
PantrixDataTable(
rows = REGIONS,
columns = remember { revenueColumns() },
rowKey = { it.name },
variant = DataTableVariant.Grid,
layout = DataTableLayout.Table,
state = state,
tagKey = "revenue"
)
}
private fun revenueColumns(): List<DataTableColumn<Region>> {
val region = DataTableColumn.text<Region>(
key = "region",
header = "Region",
width = 152.dp,
pin = DataTablePin.Start
) { it.name }
val months = MONTHS.mapIndexed { month, name ->
DataTableColumn.text<Region>(key = "m${month + 1}", header = name, width = 96.dp, alignment = Alignment.End) {
money(it.months[month])
}
}
val total = DataTableColumn.text<Region>(
key = "total",
header = "Total",
width = 120.dp,
alignment = Alignment.End,
pin = DataTablePin.End
) { money(it.months.sum()) }
return listOf(region) + months + total
}
private class Region(val name: String, val months: List<Int>)
private val MONTHS = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
private val REGIONS = listOf(
Region("North America", year(start = 42_000, growth = 1_800)),
Region("Europe", year(start = 36_500, growth = 1_200)),
Region("Asia Pacific", year(start = 28_000, growth = 2_400)),
Region("Latin America", year(start = 12_500, growth = 600)),
Region("Middle East", year(start = 9_800, growth = 450))
)
private fun year(start: Int, growth: Int): List<Int> = List(12) { month ->
start + month * growth + month % 4 * 1_500
}
private fun money(amount: Int): String = "$" + amount.toString().reversed().chunked(3).joinToString(",").reversed()
Pages from a server
A table that shows one page of rows at a time: it knows only the page on the screen, and the footer (footer) says which rows those are and moves between pages (PantrixPagination). While the next page is on its way the table is loading (loading): blocks stand in the rows' place, in the table's own columns, so that nothing jumps when the rows come.
Here the server is a pause of 600 milliseconds. A refund is written in the danger colour.
var page by remember { mutableIntStateOf(1) }
var shown by remember { mutableIntStateOf(1) }
var loading by remember { mutableStateOf(false) }
LaunchedEffect(page) {
if (page != shown) {
loading = true
delay(600)
shown = page
loading = false
}
}
val first = (shown - 1) * PAGE_SIZE
val rows = TRANSACTIONS.drop(first).take(PAGE_SIZE)
PantrixDataTable(
rows = rows,
columns = remember { transactionColumns() },
rowKey = { it.id },
loading = loading,
loadingRows = PAGE_SIZE,
footer = {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalArrangement = Arrangement.spacedBy(8.dp),
itemVerticalAlignment = Alignment.CenterVertically
) {
PantrixText(
text = PantrixTheme.strings.rowRange(first + 1, first + rows.size, TRANSACTIONS.size),
color = PantrixTheme.colors.textSecondary
)
PantrixPagination(
page = page,
pageCount = (TRANSACTIONS.size + PAGE_SIZE - 1) / PAGE_SIZE,
onPageChange = { page = it },
tagKey = "pages"
)
}
},
variant = DataTableVariant.Bordered,
layout = DataTableLayout.Table,
tagKey = "transactions"
)
private fun transactionColumns(): List<DataTableColumn<Transaction>> = listOf(
DataTableColumn.text(key = "id", header = "Transaction", width = 128.dp) { it.id },
DataTableColumn.text(key = "date", header = "Date", width = 120.dp) { it.date },
DataTableColumn.text(key = "description", header = "Description", weight = 2f, minWidth = 180.dp) {
it.description
},
DataTableColumn.text(key = "method", header = "Method", width = 176.dp) { it.method },
DataTableColumn(key = "amount", header = "Amount", width = 112.dp, alignment = Alignment.End) { transaction ->
PantrixText(
text = money(transaction.amount),
color = if (transaction.amount < 0) PantrixTheme.colors.danger.color else Color.Unspecified
)
}
)
private class Transaction(
val id: String,
val date: String,
val description: String,
val method: String,
val amount: Int
)
private val DESCRIPTIONS = listOf("Pro plan", "Team seats", "Add-on: analytics", "Consulting hours", "Hardware kit")
private val METHODS = listOf("Visa •• 4242", "Bank transfer", "Mastercard •• 1881", "PayPal")
private val TRANSACTIONS = List(46) { index ->
Transaction(
id = "TX-${8_410 + index}",
date = "Oct ${28 - index / 2}, 2026",
description = DESCRIPTIONS[index % DESCRIPTIONS.size],
method = METHODS[index % METHODS.size],
amount = (if (index % 7 == 3) -1 else 1) * (40 + index * 37 % 900)
)
}
private const val PAGE_SIZE = 8
private fun money(amount: Int): String {
val digits = kotlin.math.abs(amount).toString().reversed().chunked(3).joinToString(",").reversed()
return if (amount < 0) "−$$digits" else "$$digits"
}
Ten thousand rows
A table given a height (Modifier.height) keeps its header in place and scrolls its rows under it, and builds only the rows in view: ten thousand cost no more than ten. Scroll it with a finger, a wheel, or Page Down once a row has the focus.
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
PantrixText(
text = "10,000 events from the last three hours",
style = PantrixTheme.typography.footnote,
color = PantrixTheme.colors.textSecondary
)
PantrixDataTable(
rows = EVENTS,
columns = remember { eventColumns() },
modifier = Modifier.height(400.dp),
rowKey = { it.id },
variant = DataTableVariant.Bordered,
layout = DataTableLayout.Table,
tagKey = "events"
)
}
private fun eventColumns(): List<DataTableColumn<Event>> = listOf(
DataTableColumn.text(key = "time", header = "Time", width = 104.dp) { it.time },
DataTableColumn(key = "level", header = "Level", width = 112.dp) { event ->
PantrixTag(text = event.level.name, tone = event.level.tone)
},
DataTableColumn.text(key = "service", header = "Service", width = 120.dp) { it.service },
DataTableColumn.text(key = "message", header = "Message", weight = 3f, minWidth = 240.dp) { it.message }
)
private enum class Level(val tone: TagTone) {
Info(TagTone.Info),
Warning(TagTone.Warning),
Error(TagTone.Danger)
}
private class Event(
val id: String,
val time: String,
val level: Level,
val service: String,
val message: String
)
private val SERVICES = listOf("api", "billing", "auth", "search", "mailer")
private val MESSAGES = listOf(
"Request served in 42 ms",
"Cache warmed for 1,204 keys",
"Retrying the payment provider",
"Token refreshed for a session",
"Index rebuilt in 3.1 s",
"Queue is 80% full"
)
private val EVENTS = List(10_000) { index ->
val second = 10_800 - index
Event(
id = "$index",
time = "${10 + second / 3_600}:${(second / 60 % 60).toString().padStart(2, '0')}:" +
(second % 60).toString().padStart(2, '0'),
level = when {
index % 41 == 0 -> Level.Error
index % 9 == 0 -> Level.Warning
else -> Level.Info
},
service = SERVICES[index % SERVICES.size],
message = MESSAGES[index % MESSAGES.size]
)
}
Campaigns
A cell holds whatever its column draws: here a campaign's channel under its name, its status as a tag, what it has spent against its budget, and a line of its last twelve weeks (PantrixSparkline).
A row can be told apart as a whole. A campaign over its budget is in the danger tone (rowTone): its row takes the role's soft fill, and its text the colour the theme pairs with it. An ended campaign is closed (rowEnabled): it is drawn faded, and nothing in it can be pressed or chosen.
PantrixDataTable(
rows = CAMPAIGNS,
columns = remember { campaignColumns() },
rowKey = { it.name },
rowEnabled = { it.status != CampaignStatus.Ended },
rowTone = { if (it.spent > it.budget) DataTableTone.Danger else null },
variant = DataTableVariant.Bordered,
layout = DataTableLayout.Table,
tagKey = "campaigns"
)
private fun campaignColumns(): List<DataTableColumn<Campaign>> = listOf(
DataTableColumn(key = "campaign", header = "Campaign", weight = 2f, minWidth = 200.dp) { campaign ->
Column {
PantrixText(text = campaign.name, style = PantrixTheme.typography.label)
PantrixText(
text = campaign.channel,
style = PantrixTheme.typography.footnote,
color = PantrixTheme.colors.textSecondary
)
}
},
DataTableColumn(key = "status", header = "Status", width = 112.dp) { campaign ->
PantrixTag(text = campaign.status.name, tone = campaign.status.tone, dot = true)
},
DataTableColumn.text(key = "spent", header = "Spent", width = 168.dp, alignment = Alignment.End) {
"${money(it.spent)} of ${money(it.budget)}"
},
DataTableColumn.text(key = "conversions", header = "Conversions", width = 120.dp, alignment = Alignment.End) {
"${it.trend.sum()}"
},
DataTableColumn(key = "trend", header = "Last 12 weeks", width = 144.dp) { campaign ->
PantrixSparkline(
values = campaign.trend,
modifier = Modifier.size(width = 112.dp, height = 28.dp),
contentDescription = "Conversions of ${campaign.name} over the last twelve weeks",
markLast = true
)
}
)
private enum class CampaignStatus(val tone: TagTone) {
Active(TagTone.Success),
Paused(TagTone.Warning),
Ended(TagTone.Neutral)
}
private class Campaign(
val name: String,
val channel: String,
val status: CampaignStatus,
val spent: Int,
val budget: Int,
val trend: List<Int>
)
private val CAMPAIGNS = listOf(
Campaign(
"Autumn sale",
"Search ads",
CampaignStatus.Active,
3_240,
5_000,
listOf(12, 18, 15, 22, 28, 26, 31, 35, 33, 41, 46, 52)
),
Campaign(
"Product launch",
"Social",
CampaignStatus.Active,
6_480,
6_000,
listOf(5, 9, 14, 30, 48, 44, 39, 36, 31, 29, 27, 24)
),
Campaign(
"Newsletter revival",
"Email",
CampaignStatus.Paused,
820,
2_000,
listOf(20, 19, 21, 18, 17, 16, 15, 15, 14, 12, 12, 11)
),
Campaign(
"Summer webinar",
"Events",
CampaignStatus.Ended,
4_100,
4_500,
listOf(3, 8, 16, 25, 31, 18, 6, 2, 1, 0, 0, 0)
)
)
private fun money(amount: Int): String = "$" + amount.toString().reversed().chunked(3).joinToString(",").reversed()
Looks
The same shipments in each of the table's looks (variant): Plain stands on the page with a line between the rows, Bordered puts one edge around the whole table, and Grid draws a line between the columns as well. Stripes (striped) fill every other row, which helps the eye along a long row.
var variant by remember { mutableStateOf(DataTableVariant.Bordered) }
var striped by remember { mutableStateOf(true) }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
itemVerticalAlignment = Alignment.CenterVertically
) {
PantrixSegmentButton(
options = remember {
DataTableVariant.entries.map { SegmentOption(it, it.name, tagKey = it.name.lowercase()) }
},
selected = variant,
onSelect = { variant = it },
fullWidth = false,
tagKey = "variant"
)
PantrixSwitchButton(
checked = striped,
onCheckedChange = { striped = it },
label = "Stripes",
tagKey = "striped"
)
}
PantrixDataTable(
rows = SHIPMENTS,
columns = remember { shipmentColumns() },
variant = variant,
striped = striped,
layout = DataTableLayout.Table,
tagKey = "shipments"
)
}
private fun shipmentColumns(): List<DataTableColumn<Shipment>> = listOf(
DataTableColumn.text(key = "tracking", header = "Tracking", width = 136.dp) { it.tracking },
DataTableColumn.text(key = "destination", header = "Destination", weight = 2f, minWidth = 160.dp) {
it.destination
},
DataTableColumn.text(key = "carrier", header = "Carrier", width = 112.dp) { it.carrier },
DataTableColumn.text(key = "eta", header = "Arrives", width = 112.dp) { it.eta },
DataTableColumn.text(key = "weight", header = "Weight", width = 96.dp, alignment = Alignment.End) {
"${it.weight} kg"
}
)
private class Shipment(
val tracking: String,
val destination: String,
val carrier: String,
val eta: String,
val weight: Double
)
private val SHIPMENTS = listOf(
Shipment("1Z 884 201", "Rotterdam, Netherlands", "UPS", "Nov 2", 12.4),
Shipment("JD 014 772", "Lyon, France", "DHL", "Nov 3", 3.1),
Shipment("9400 1109", "Austin, United States", "USPS", "Nov 4", 0.8),
Shipment("CX 552 918", "Osaka, Japan", "FedEx", "Nov 6", 21.0),
Shipment("RR 330 845", "Kraków, Poland", "DPD", "Nov 6", 6.5)
)
Loading and empty
The table keeps its header in both states. While it is loading (loading), blocks stand in its rows' place; with no rows, it shows what empty gives it, here a word of what is missing and a way to start, or the theme's "No rows" where nothing is given. It stays a few rows tall, so that a filter that empties it does not fold the page up.
var shown by remember { mutableStateOf(Shown.Rows) }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
PantrixSegmentButton(
options = remember { Shown.entries.map { SegmentOption(it, it.name, tagKey = it.name.lowercase()) } },
selected = shown,
onSelect = { shown = it },
fullWidth = false,
tagKey = "shown"
)
PantrixDataTable(
rows = if (shown == Shown.Rows) FILES else emptyList(),
columns = remember { fileColumns() },
loading = shown == Shown.Loading,
loadingRows = FILES.size,
empty = { NoFiles() },
variant = DataTableVariant.Bordered,
layout = DataTableLayout.Table,
tagKey = "files"
)
}
@Composable
private fun NoFiles() {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
PantrixIcon(
imageVector = PantrixIcons.Folder,
contentDescription = null,
size = 32.dp,
tint = PantrixTheme.colors.textSecondary
)
PantrixText(text = "No files yet", style = PantrixTheme.typography.label)
PantrixText(
text = "Files you upload are listed here.",
style = PantrixTheme.typography.footnote,
color = PantrixTheme.colors.textSecondary
)
PantrixButton(
text = "Upload a file",
onClick = {},
variant = ButtonVariant.Outline,
size = ButtonSize.Small,
leadingIcon = { PantrixIcon(imageVector = PantrixIcons.Upload, contentDescription = null) },
tagKey = "upload"
)
}
}
private fun fileColumns(): List<DataTableColumn<SharedFile>> = listOf(
DataTableColumn.text(key = "name", header = "Name", weight = 2f, minWidth = 200.dp) { it.name },
DataTableColumn.text(key = "owner", header = "Owner", width = 140.dp) { it.owner },
DataTableColumn.text(key = "modified", header = "Modified", width = 128.dp) { it.modified },
DataTableColumn.text(key = "size", header = "Size", width = 96.dp, alignment = Alignment.End) { it.size }
)
private enum class Shown { Rows, Loading, Empty }
private class SharedFile(
val name: String,
val owner: String,
val modified: String,
val size: String
)
private val FILES = listOf(
SharedFile("Brand guidelines.pdf", "Mia Robinson", "Today", "4.2 MB"),
SharedFile("Q4 roadmap.key", "Ava Thompson", "Yesterday", "18.7 MB"),
SharedFile("Pricing study.xlsx", "Noah Garcia", "Oct 24", "860 KB"),
SharedFile("Onboarding flow.fig", "Ethan Clark", "Oct 21", "12.3 MB")
)
A list of records
The same orders as a table, or as a list of records: each order a block of its own, each value under its column's name. The table is a list where it is told to be one (DataTableLayout.List). Auto lets its own width choose: narrower than the style's compactWidth, 600dp, it is a list, and a table otherwise; the table in a narrow pane of a wide screen is a list too.
The list keeps what the table could do: the boxes that choose a row, and the order, by the names of the columns that sort in a strip over the records.
var layout by remember { mutableStateOf(DataTableLayout.List) }
var sort by remember { mutableStateOf(emptyList<DataTableSort>()) }
var chosen by remember { mutableStateOf(emptySet<String>()) }
val columns = remember { orderColumns() }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
PantrixSegmentButton(
options = remember {
DataTableLayout.entries.map { SegmentOption(it, it.name, tagKey = it.name.lowercase()) }
},
selected = layout,
onSelect = { layout = it },
fullWidth = false,
tagKey = "layout"
)
PantrixDataTable(
rows = remember(sort) { ORDERS.sortedWith(sort.comparator(columns)) },
columns = columns,
rowKey = { it.id },
rowLabel = { it.customer },
sort = sort,
onSortChange = { sort = it },
selected = chosen,
onSelectionChange = { chosen = it },
variant = DataTableVariant.Bordered,
layout = layout,
tagKey = "records"
)
}
private fun orderColumns(): List<DataTableColumn<Order>> = listOf(
DataTableColumn.text(key = "id", header = "Order", width = 96.dp) { "#${it.id}" },
DataTableColumn.text(key = "customer", header = "Customer", weight = 2f, sortable = true) { it.customer },
DataTableColumn(key = "status", header = "Status", width = 128.dp) { order ->
PantrixTag(text = order.status, tone = if (order.status == "Paid") TagTone.Success else TagTone.Warning)
},
DataTableColumn.text(
key = "total",
header = "Total",
width = 112.dp,
alignment = Alignment.End,
sortable = true,
comparator = compareBy<Order> { it.total }
) { money(it.total) }
)
private class Order(
val id: String,
val customer: String,
val status: String,
val total: Int
)
private val ORDERS = listOf(
Order("3210", "Olivia Martin", "Paid", 1999),
Order("3209", "Jackson Lee", "Pending", 39),
Order("3208", "Isabella Nguyen", "Paid", 299),
Order("3207", "William Kim", "Pending", 99)
)
private fun money(amount: Int): String = "$" + amount.toString().reversed().chunked(3).joinToString(",").reversed()
API
rows
Rows of records in columns: a header that orders them, boxes that choose them, rows that open, columns that stay in place while the others scroll, and a list of records where there is no room for a table.
| Parameter | Type | Default | What it is |
|---|---|---|---|
rows | List<T> | the rows, in the order they are shown. | |
columns | List<DataTableColumn<T>> | the columns, in the order they are shown. No two of them have one key. Keep the list in a remember: a table given its columns anew at every composition lays every row out again. | |
modifier | Modifier | Modifier | the table's modifier. The table is as wide as the room it is given. |
rowKey | ((row: T) -> String)? | null | what tells a row from the others, the same while the row is the same: what selected and expanded hold, and the row's automation key. Rows that are chosen or opened need one. Without it a row is known by its place. |
rowLabel | ((row: T) -> String)? | null | what a screen reader calls a row by: Select Ada Lovelace in place of twenty boxes that all say Select row. |
rowEnabled | ((row: T) -> Boolean)? | null | whether a row can be chosen and pressed. One that cannot is drawn fainter, its box is closed, and the box in the header neither chooses it nor lets it go. Its chevron opens it all the same, and what its cells hold stays the caller's to close. A table whose every row is closed is given { false }. |
rowTone | ((row: T) -> DataTableTone?)? | null | what a row means, or null for a row like the others: the row lies on the soft fill of the tone's role, over any stripe, and what it shows is written in the colour the theme pairs with that fill. |
sort | List<DataTableSort> | emptyList() | the order the rows are in: the columns it goes by, the first one first. It is drawn in the headers. |
onSortChange | ((List<DataTableSort>) -> Unit)? | null | called with the order a press on a sortable column's header asks for: smallest first, then largest first, then no order. Null leaves the headers as names. |
multiSort | Boolean | false | whether a press adds its column to the order, to settle the rows the columns before it hold equal, in place of ordering by that column alone. |
selected | Set<String> | emptySet() | the keys of the rows that are chosen. |
onSelectionChange | ((Set<String>) -> Unit)? | null | called with the keys that are to be chosen after a box was pressed. Null leaves the table without boxes. |
selection | DataTableSelection | DataTableSelection.Multiple | whether many rows are chosen or one. |
expanded | Set<String> | emptySet() | the keys of the rows that are open. |
onExpandedChange | ((Set<String>) -> Unit)? | null | called with the keys that are to be open after a chevron was pressed. Null leaves the rows without chevrons: the rows named in expanded are open all the same. |
expansion | DataTableExpansion | DataTableExpansion.Multiple | whether many rows are open at a time or one. Where it is one, a chevron that opens a row asks for that row alone, and the row that was open closes; the rows named in expanded are open as they are given. |
rowDetail | (@Composable (row: T) -> Unit)? | null | what an open row shows under itself, as wide as the table. |
onRowClick | ((row: T) -> Unit)? | null | called with a row that was pressed. It makes every row a button. |
loading | Boolean | false | whether the rows are still on their way: blocks stand in their place, in the table's columns. |
loadingRows | Int | 5 | how many rows of blocks stand there. |
empty | (@Composable () -> Unit)? | null | what a table without rows shows in their place, in place of the theme's words for it. |
toolbar | (@Composable () -> Unit)? | null | what stands over the header inside the table's edge: a title, a search field, what is done with the chosen rows. |
footer | (@Composable () -> Unit)? | null | what stands under the rows inside the table's edge: a PantrixPagination, a count. |
listContent | (@Composable (row: T) -> Unit)? | null | what a record of the list shows, in place of its columns' names and values. |
variant | DataTableVariant | DataTableVariant.Plain | how much is drawn around the rows. |
striped | Boolean | false | whether every other row is laid on a faint fill, to keep the eye on its line. |
layout | DataTableLayout | DataTableLayout.Auto | whether this is a table, a list, or whichever its width has room for. |
headerTextStyle | TextStyle | PantrixTheme.typography.label.copy(fontWeight = FontWeight.SemiBold) | what the columns' names are written in: the control label's type, a little heavier. A table whose headers are small capitals set apart gives that type here, and writes its headers in capitals. |
state | DataTableState | rememberDataTableState() | where the rows and the columns are scrolled to and the widths the columns were dragged to. |
style | DataTableStyle? | null | this call's own style: over every enclosing one and under the density. Only the fields it sets change, on every platform. |
colors | DataTableColors? | null | this table's own colours, where they are not the theme's: the ones set here, with the theme's for the rest. |
tagKey | String? | null | the table's automation key: the table is DataTable_<tagKey>, and in it a header is Header_<column>, a row Row_<row>, a cell Cell_<row>/<column>, a row's box Select_<row>, its chevron Toggle_<row> and what it opens Detail_<row>. A press is reported as a DataTable event: sort, select, selectAll, expand, collapse and open. |
DataTableStyle
PantrixDataTable's geometry: how tall a header and a row are at the least, the room a cell has around what it shows, and the edge.
| Parameter | Type | Default | What it is |
|---|---|---|---|
shape | ShapeSpec | ShapeSpec.Unspecified | The corners of the edge around the table in DataTableVariant.Bordered and DataTableVariant.Grid: a step of the brand's shape scale, or a fixed shape. |
headerMinHeight | Dp | Dp.Unspecified | The least height of the header. A header's name on two lines makes it taller. |
rowMinHeight | Dp | Dp.Unspecified | The least height of a row. What a cell shows makes its row taller, and a row that holds a control is no lower than the platform's touch target. |
cellPadding | Dp | Dp.Unspecified | The room between a cell's sides and what it shows. |
cellVerticalPadding | Dp | Dp.Unspecified | The room over and under what a cell shows, where that needs more than the least height. |
columnMinWidth | Dp | Dp.Unspecified | The least width of a column that shares the table's width and names no least of its own. The columns keep it where the table is too narrow for them, and the table scrolls sideways. |
sortIconSize | Dp | Dp.Unspecified | The width and height of the arrow beside the header of the column the rows are in the order of. |
borderWidth | Dp | Dp.Unspecified | The width of the lines between the rows and the columns, and of the edge. Zero draws none. |
compactWidth | Dp | Dp.Unspecified | Narrower than this, a table of DataTableLayout.Auto is drawn as a list of records. |
expander | DataTableExpander? | null | Where the button that opens a row stands: at the row's end on every platform, as a list's chevron does. Null leaves it as it is. |
DataTableColors
What a PantrixDataTable is painted with, for a table that does not take the theme's colours: a colour that is set is used, and one that is left out stays the theme's.
| Parameter | Type | Default | What it is |
|---|---|---|---|
container | Color | Color.Unspecified | The fill behind the whole table. None unless given: a table stands on what is behind it, a card's surface or a page. |
content | Color | Color.Unspecified | What a cell is given for what it shows, where that gives no colour of its own, and what a row's own marks are drawn in: the chevron that opens it, the mark of a pointer over it. The theme's textPrimary, or on a fill that is not the table's, the colour that fill gives what stands on it. |
headerContainer | Color | Color.Unspecified | The fill behind the header. None unless given. |
headerContent | Color | Color.Unspecified | The headers' names and the sort arrow. The theme's textSecondary on the theme's own grounds, the colour paired with headerContainer where that is a role's, and content elsewhere. |
stripe | Color | Color.Unspecified | The fill behind every other row of a striped table: a faint share of content unless given. |
selected | Color | Color.Unspecified | The fill behind a chosen row: a faint share of the theme's primary on the theme's own grounds, and of content on a fill. |
divider | Color | Color.Unspecified | The lines between the rows and the columns, and the edge. The theme's divider on the theme's own grounds. On a fill, where that line would not read, a share of content: the edge a subtle button has. |
Accessibility
- Keyboard. Tab goes through the headers that sort, the boxes, the chevrons, the rows that are pressed and whatever the cells hold; the arrow keys move the focus to the nearest of them that way. A cell that holds nothing to press is no stop: this is a table to read and to act on, and not a grid to walk cell by cell. Where there is a pointer, the rows' window is a stop too while the table scrolls: there the arrow keys, Page Up, Page Down, Home and End scroll the table.
- Screen reader. A header says that it is a header and how the rows are ordered by its column. Where
rowLabelis given a row's box is read by the row's name: "Select Ada Lovelace". A cell says which row and column it is in.