Skip to content

Instantly share code, notes, and snippets.

@elkhoudiry
Last active March 16, 2024 17:25
Show Gist options
  • Save elkhoudiry/a462fd277e2531d541932f2217ab3687 to your computer and use it in GitHub Desktop.
Save elkhoudiry/a462fd277e2531d541932f2217ab3687 to your computer and use it in GitHub Desktop.
Compose Multiplatform Data Table / Dynamic Table , supports vertical and horizontal scrolling, columns ordering
package <>
import androidx.compose.foundation.LocalScrollbarStyle
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.ScrollbarStyle
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.rememberScrollbarAdapter
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
actual fun VerticalScrollbar(modifier: Modifier, state: LazyListState) {
androidx.compose.foundation.VerticalScrollbar(
modifier = modifier,
adapter = rememberScrollbarAdapter(scrollState = state),
style = scrollBarStyle()
)
}
@Composable
actual fun HorizontalScrollbar(modifier: Modifier, state: ScrollState) {
androidx.compose.foundation.HorizontalScrollbar(
modifier = modifier,
adapter = rememberScrollbarAdapter(scrollState = state),
style = scrollBarStyle()
)
}
@Composable
private fun scrollBarStyle(): ScrollbarStyle {
return LocalScrollbarStyle.current.copy(
unhoverColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
)
}
data class Demo(
val name: String = "John Doe Lorem Ipsum",
val age: Int = Random.nextInt(5, 100),
val bong: Boolean = Random.nextBoolean(),
val song: Double = (Random.nextDouble() * 100).toString().split(".").map { it.take(2) }.joinToString(".").toDouble()
)
@Composable
private fun MainHomeTable() {
DynamicTable(
titles = listOf(
"Name",
"Do",
"Bong",
"Soon",
"Name",
"Do",
"Bong",
"Soon",
"Name",
"Do",
"Bong",
"Soon",
"Name",
"Do",
"Bong",
"Soon",
),
items = (1..1000).map { Demo() },
isStickyHeader = true,
mapper = { row ->
listOf(
CellPresentation(row.name, true) { },
CellPresentation(row.age, true) { },
CellPresentation(row.bong, true) { },
CellPresentation(row.song, true) { },
CellPresentation(row.name, true) { },
CellPresentation(row.age, true) { },
CellPresentation(row.bong, true) { },
CellPresentation(row.song, true) { },
CellPresentation(row.name, true) { },
CellPresentation(row.age, true) { },
CellPresentation(row.bong, true) { },
CellPresentation(row.song, true) { },
CellPresentation(row.name, true) { },
CellPresentation(row.age, true) { },
CellPresentation(row.bong, true) { },
CellPresentation(row.song, true) { },
)
}
)
}
package <>
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun <T> DynamicTable(
modifier: Modifier = Modifier,
items: List<T>,
titles: List<String>,
isStickyHeader: Boolean,
fontSize: Float = 14f,
mapper: (T) -> List<CellPresentation<*>>,
) {
var viewPortWidth by remember { mutableStateOf<Float?>(null) }
var headers by remember {
mutableStateOf((listOf("") + titles).map {
HeaderCellPresentation(
it,
null
)
})
}
val rowsOriginal = remember {
items.mapIndexed { index, t ->
val mapped = mapper(t)
if (mapped.size != titles.size) throw IllegalStateException(
"The mapper must return the same number of cells as the number of columns"
)
listOf(CellPresentation(index + 1, false) {}) + mapped
}
}
val rowsDisplay = remember(headers, rowsOriginal) {
when (val sorted = headers.indexOfFirst { it.isSortedAsc != null }) {
-1 -> rowsOriginal
else -> {
rowsOriginal.sortedWith { a, b ->
if (headers[sorted].isSortedAsc == true) {
a[sorted].compareTo(b[sorted])
} else {
b[sorted].compareTo(a[sorted])
}
}
}
}
}
val columnsLengths = remember { calculateWeights(headers, rowsOriginal) }
val isWeighted = remember(columnsLengths, viewPortWidth) {
viewPortWidth != null && columnsLengths.sum() * fontSize < viewPortWidth!!
}
val weights =
remember(columnsLengths) { columnsLengths.map { it.toFloat() / columnsLengths.sum() } }
val verticalScrollState = rememberLazyListState(0)
val horizontalScrollState = rememberScrollState(0)
Box(
modifier.fillMaxSize().onGloballyPositioned {
viewPortWidth = it.size.width.toFloat()
}
) {
if (isStickyHeader) {
StickyHeaderTableContent(
modifier = modifier,
headers = headers,
rowsDisplay = rowsDisplay,
lengths = columnsLengths,
weights = weights,
isWeighted = isWeighted,
viewPortWidth = viewPortWidth,
verticalScrollState = verticalScrollState,
horizontalScrollState = horizontalScrollState,
onHeadersChange = { headers = it },
fontSize = fontSize
)
} else {
TableContent(
modifier = modifier,
headers = headers,
rowsDisplay = rowsDisplay,
lengths = columnsLengths,
weights = weights,
isWeighted = isWeighted,
isHeaderSticky = false,
viewPortWidth = viewPortWidth,
verticalScrollState = verticalScrollState,
horizontalScrollState = horizontalScrollState,
onHeadersChange = { headers = it },
fontSize = fontSize
)
}
}
}
@Composable
private fun StickyHeaderTableContent(
modifier: Modifier,
headers: List<HeaderCellPresentation>,
rowsDisplay: List<List<CellPresentation<*>>>,
lengths: List<Int>,
weights: List<Float>,
isWeighted: Boolean,
viewPortWidth: Float?,
verticalScrollState: LazyListState,
horizontalScrollState: ScrollState,
fontSize: Float,
onHeadersChange: (List<HeaderCellPresentation>) -> Unit
) {
Column {
BaseTableRow(
modifier = Modifier
.scrollHorizontally(horizontalScrollState, viewPortWidth, isWeighted),
cells = headers.mapIndexed { index, cell ->
cell.toCell { onHeadersChange(headers.sorted(index)) }
},
lengths = lengths,
weights = weights,
isWeighted = isWeighted,
background = MaterialTheme.colorScheme.primaryContainer,
fontSize = fontSize
)
TableContent(
modifier = modifier,
headers = headers,
rowsDisplay = rowsDisplay,
lengths = lengths,
weights = weights,
isWeighted = isWeighted,
isHeaderSticky = true,
viewPortWidth = viewPortWidth,
verticalScrollState = verticalScrollState,
horizontalScrollState = horizontalScrollState,
onHeadersChange = onHeadersChange,
fontSize = fontSize
)
}
}
@Composable
private fun TableContent(
modifier: Modifier,
headers: List<HeaderCellPresentation>,
rowsDisplay: List<List<CellPresentation<*>>>,
lengths: List<Int>,
weights: List<Float>,
isWeighted: Boolean,
isHeaderSticky: Boolean,
viewPortWidth: Float?,
verticalScrollState: LazyListState,
horizontalScrollState: ScrollState,
fontSize: Float,
onHeadersChange: (List<HeaderCellPresentation>) -> Unit
) {
Box(
modifier = modifier.fillMaxWidth(),
) {
LazyColumn(
Modifier
.scrollHorizontally(horizontalScrollState, viewPortWidth, isWeighted),
verticalScrollState
) {
if (!isHeaderSticky) {
item(key = headers) {
BaseTableRow(
modifier = Modifier,
cells = headers.mapIndexed { index, cell ->
cell.toCell { onHeadersChange(headers.sorted(index)) }
},
lengths = lengths,
weights = weights,
isWeighted = isWeighted,
fontSize = fontSize,
background = MaterialTheme.colorScheme.primaryContainer
)
}
}
itemsIndexed(
rowsDisplay,
key = { index, _: List<CellPresentation<*>> -> index }) { _, row: List<CellPresentation<*>> ->
BaseTableRow(
modifier = Modifier,
cells = row,
lengths = lengths,
weights = weights,
isWeighted = isWeighted,
fontSize = fontSize,
background = MaterialTheme.colorScheme.surface
)
}
}
VerticalScrollbar(
modifier = Modifier.align(Alignment.CenterEnd),
state = verticalScrollState
)
HorizontalScrollbar(
modifier = Modifier.align(Alignment.BottomStart),
state = horizontalScrollState
)
}
}
@Composable
private fun BaseTableRow(
modifier: Modifier,
background: Color,
cells: List<CellPresentation<*>>,
lengths: List<Int>,
weights: List<Float>,
fontSize: Float,
isWeighted: Boolean,
) {
Row(
modifier
.background(background)
.height(IntrinsicSize.Min)
) {
for (j in cells.indices) {
BaseTableCell(
text = cells[j].data,
width = lengths[j],
weight = weights[j],
isWeighted = isWeighted,
clickEnabled = cells[j].icClickable,
fontSize = fontSize,
onClick = cells[j].onClick
)
}
}
}
@Composable
private fun RowScope.BaseTableCell(
text: Any?,
weight: Float,
width: Int,
isWeighted: Boolean,
clickEnabled: Boolean,
fontSize: Float,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxHeight()
.border(1.dp, Color.Black)
.widthIn((width * fontSize).dp)
.then(if (isWeighted) Modifier.weight(weight) else Modifier)
.padding(horizontal = 2.dp, vertical = 1.dp)
.clickable(enabled = clickEnabled, onClick = onClick),
contentAlignment = Alignment.CenterStart
) {
Text(
text = text.toString(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
fontSize = fontSize.sp
)
}
}
private fun calculateWeights(
titles: List<HeaderCellPresentation>,
contents: List<List<CellPresentation<*>>>,
): List<Int> {
val lengths = titles
.map { it.text.length }
.toMutableList()
for (i in titles.indices) {
val colContents = contents.map { it[i].data.toString() }
var largest = titles[i].text.length + 2 // 2 is for the space " " and the arrow "▲" or "▼"
for (content in colContents) {
val contentLength = content.split("\n").maxOf { it.length }
if (contentLength > largest) largest = contentLength
}
lengths[i] = largest
}
return lengths
}
data class CellPresentation<T : Comparable<T>>(
val data: T,
val icClickable: Boolean,
val onClick: () -> Unit,
) {
fun compareTo(other: CellPresentation<*>): Int {
return data.compareTo(other.data as T)
}
}
private data class HeaderCellPresentation(
val text: String,
val isSortedAsc: Boolean? = null
) {
fun toCell(onClick: () -> Unit): CellPresentation<String> {
val arrow = when (isSortedAsc) {
null -> ""
true -> "▲"
else -> "▼"
}
val text = "$text $arrow"
return CellPresentation(text, true, onClick)
}
}
@Composable
expect fun VerticalScrollbar(modifier: Modifier, state: LazyListState)
@Composable
expect fun HorizontalScrollbar(modifier: Modifier, state: ScrollState)
private fun Modifier.scrollHorizontally(
horizontalScrollState: ScrollState,
viewPortWidth: Float?,
isWeighted: Boolean
): Modifier {
return this.fillMaxWidth()
.horizontalScroll(horizontalScrollState)
.padding()
.then(if (viewPortWidth != null && isWeighted) Modifier.widthIn(max = viewPortWidth.dp) else Modifier)
}
private fun List<HeaderCellPresentation>.sorted(index: Int): List<HeaderCellPresentation> {
val item = this[index]
val newItem = when (item.isSortedAsc) {
null -> item.copy(isSortedAsc = true)
true -> item.copy(isSortedAsc = false)
false -> item.copy(isSortedAsc = null)
}
val sorted = this.map { it.copy(isSortedAsc = null) }.toMutableList()
sorted[index] = newItem
return sorted
}
@elkhoudiry
Copy link
Author

simplescreenrecorder-2024-03-16_19 01 06 (6)

@elkhoudiry
Copy link
Author

image
image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment