-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) { }, | |
) | |
} | |
) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment