I want to create table views, like the one below, to show the data I have.
A header | Another header |
---|---|
First | row |
Second | row |
I tried using LazyVerticalGrid
to achieve it but Jetpack Compose doesn’t allow me to put LazyVerticalGrid
inside a vertically scrollable Column
.
It’s been two days and I’m really out of idea. Please help.
We can make the Column scrollable by using the verticalScroll() modifier.
A LazyColumn is a vertically scrolling list that only composes and lays out the currently visible items. It's similar to a Recyclerview in the classic Android View system.
Jetpack Compose is a modern declarative UI Toolkit for Android. Compose makes it easier to write and maintain your app UI by providing a declarative API that allows you to render your app UI without imperatively mutating frontend views.
Use LaunchedEffect() when you are using coroutines and need to cancel and relaunch the coroutine every time your parameter changes and it isn't stored in a mutable state. DisposableEffect() is useful when you aren't using coroutines and need to dispose and relaunch the event every time your parameter changes.
As far as I know, there's no built-in component to that. But it's actually easy to do it with LazyColumn
and using the same weight
for all lines of the same column.
See this example:
First, you can define a cell for your table:
@Composable
fun RowScope.TableCell(
text: String,
weight: Float
) {
Text(
text = text,
Modifier
.border(1.dp, Color.Black)
.weight(weight)
.padding(8.dp)
)
}
Then you can use it to build your table:
@Composable
fun TableScreen() {
// Just a fake data... a Pair of Int and String
val tableData = (1..100).mapIndexed { index, item ->
index to "Item $index"
}
// Each cell of a column must have the same weight.
val column1Weight = .3f // 30%
val column2Weight = .7f // 70%
// The LazyColumn will be our table. Notice the use of the weights below
LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
// Here is the header
item {
Row(Modifier.background(Color.Gray)) {
TableCell(text = "Column 1", weight = column1Weight)
TableCell(text = "Column 2", weight = column2Weight)
}
}
// Here are all the lines of your table.
items(tableData) {
val (id, text) = it
Row(Modifier.fillMaxWidth()) {
TableCell(text = id.toString(), weight = column1Weight)
TableCell(text = text, weight = column2Weight)
}
}
}
}
Here is the result:
An implementation that supports both fixed and variable column widths, and is horizontally and vertically scrollable would look like the following:
@Composable
fun Table(
modifier: Modifier = Modifier,
rowModifier: Modifier = Modifier,
verticalLazyListState: LazyListState = rememberLazyListState(),
horizontalScrollState: ScrollState = rememberScrollState(),
columnCount: Int,
rowCount: Int,
beforeRow: (@Composable (rowIndex: Int) -> Unit)? = null,
afterRow: (@Composable (rowIndex: Int) -> Unit)? = null,
cellContent: @Composable (columnIndex: Int, rowIndex: Int) -> Unit
) {
val columnWidths = remember { mutableStateMapOf<Int, Int>() }
Box(modifier = modifier.then(Modifier.horizontalScroll(horizontalScrollState))) {
LazyColumn(state = verticalLazyListState) {
items(rowCount) { rowIndex ->
Column {
beforeRow?.invoke(rowIndex)
Row(modifier = rowModifier) {
(0 until columnCount).forEach { columnIndex ->
Box(modifier = Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val existingWidth = columnWidths[columnIndex] ?: 0
val maxWidth = maxOf(existingWidth, placeable.width)
if (maxWidth > existingWidth) {
columnWidths[columnIndex] = maxWidth
}
layout(width = maxWidth, height = placeable.height) {
placeable.placeRelative(0, 0)
}
}) {
cellContent(columnIndex, rowIndex)
}
}
}
afterRow?.invoke(rowIndex)
}
}
}
}
}
The benefit of this implementation is that offers greater flexibility and a relatively simple API. However, there are known caveats, including: variable width columns are less performant and column dividers would have to be done on the cell level.
For variable width columns, it is not as performant as it would require multiple "layout passes". Essentially, this implementation lays out each Row
in a LazyColumn
, measuring each column cell in each row and storing the largest width value in a MutableState
. When a larger width value is encountered for a column, it would trigger a recompose, adjusting all of the other cells in that column to be the larger width. This way, every cell in a column has the same width (and every cell in a row has the same height). For fixed width columns, the performance should be equivalent to other implementations as it doesn't require multiple "layout passes".
Usage:
Table(
modifier = Modifier.matchParentSize(),
columnCount = 3,
rowCount = 10,
cellContent = { columnIndex, rowIndex ->
Text("Column: $columnIndex; Row: $rowIndex")
})
Overloaded composable functions that use the above implementation should be fairly trivial to create, such as having a "header row" be the first row in the table.
My solution is not perfect, but at least the horizontal scroll works. The best I have not found and did not come up with. I hope Google releases its own implementation of DataTable. At the moment DataTable is not implemented for Android. To set the width of the columns, you have to calculate their weights, but this calculation is not accurate.
private fun calcWeights(columns: List<String>, rows: List<List<String>>): List<Float> {
val weights = MutableList(columns.size) { 0 }
val fullList = rows.toMutableList()
fullList.add(columns)
fullList.forEach { list ->
list.forEachIndexed { columnIndex, value ->
weights[columnIndex] = weights[columnIndex].coerceAtLeast(value.length)
}
}
return weights
.map { it.toFloat() }
}
@Composable
fun SimpleTable(columnHeaders: List<String>, rows: List<List<String>>) {
val weights = remember { mutableStateOf(calcWeights(columnHeaders, rows)) }
Column(
modifier = Modifier
.horizontalScroll(rememberScrollState())
) {
/* HEADER */
Row(modifier = Modifier.fillMaxWidth()) {
columnHeaders.forEachIndexed { rowIndex, cell ->
val weight = weights.value[rowIndex]
SimpleCell(text = cell, weight = weight)
}
}
/* ROWS */
LazyColumn(modifier = Modifier) {
itemsIndexed(rows) { rowIndex, row ->
Row(
modifier = Modifier
.fillMaxWidth()
) {
row.forEachIndexed { columnIndex, cell ->
val weight = weights.value[columnIndex]
SimpleCell(text = cell, weight = weight)
}
}
}
}
}
}
@Composable
private fun SimpleCell(
text: String,
weight: Float = 1f
) {
val textStyle = MaterialTheme.typography.body1
val fontWidth = textStyle.fontSize.value / 2.2f // depends of font used(
val width = (fontWidth * weight).coerceAtMost(500f)
val textColor = MaterialTheme.colors.onBackground
Text(
text = text,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
color = textColor,
modifier = Modifier
.border(0.dp, textColor.copy(alpha = 0.5f))
.fillMaxWidth()
.width(width.dp + Size.marginS.times(2))
.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With