I am trying to render a simple time table in Jetpack Compose on Android. Some lectures have a different number of allocated blocks so I decided to use LazyHorizontalStaggeredGrid because the widths of items will differ but the heights will remain the same.
Next image shows how the time table should look like.

When a first item with different width than the rest is added into the time table, it messes the order of all items that come after it completely. The following image shows the problem around 14:00 hour mark on Android emulator.

The code is rather simple. I define a flat list of items specifying all items in one column before moving onto the next column. Then I send that list into a TimeTable composable that defines the LazyHorizontalStaggeredGrid. And finally each item is rendered with a TableCell composable that's just a Box with different width based on the number of blocks the subject has allocated.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
TimeTable(defaultCells)
}
}
}
}
private class Cell(
val text: String,
val blocks: Int = 2,
val bgColor: Color = Color.White,
val textColor: Color = Color.Black,
)
private fun empty(blocks: Int = 1) = Cell(
blocks = blocks,
text = "",
)
private fun header(value: String, blocks: Int = 1) = Cell(
blocks = blocks,
text = value,
bgColor = Color(153, 153, 153),
textColor = Color.White,
)
enum class SubjectColor(val bg: Color, val text: Color) {
RED(Color(234, 153, 153), Color.White),
BLUE(Color(207, 226, 243), Color.Black),
}
private fun subject(name: String, blocks: Int = 2, color: SubjectColor = SubjectColor.RED) = Cell(
blocks = blocks,
text = name,
bgColor = color.bg,
textColor = color.text,
)
private val defaultCells: List<Cell>
get() = listOf(
empty(),
header("MO"),
header("TU"),
header("WE"),
header("TH"),
header("FR"),
header("08:00", blocks = 2),
empty(2),
empty(2),
subject("Subject 1"),
empty(2),
empty(2),
header("10:00", blocks = 2),
empty(2),
empty(2),
subject("Subject 1", color = BLUE),
subject("Subject 2"),
empty(2),
header("12:00", blocks = 2),
subject("Subject 3", blocks = 3),
subject("Subject 4"),
subject("Subject 5"),
empty(2),
empty(2),
header("14:00", blocks = 2),
empty(),
subject("Subject 2", color = BLUE),
subject("Subject 3", color = BLUE),
subject("Subject 4", blocks = 3, color = BLUE),
subject("Subject 6"),
header("16:00", blocks = 2),
subject("Subject 5", color = BLUE),
subject("Subject 7"),
subject("Subject 7", color = BLUE),
empty(),
subject("Subject 6", color = BLUE),
)
@Composable
private fun TimeTable(
cells: List<Cell>,
rows: Int = 6,
) {
LazyHorizontalStaggeredGrid(
rows = StaggeredGridCells.Fixed(rows),
modifier = Modifier.fillMaxSize(),
) {
items(cells) { cell ->
TableCell(cell)
}
}
}
@Composable
private fun TableCell(
cell: Cell,
blockWidth: Dp = 100.dp,
rowHeight: Dp = 50.dp,
) {
Box(
modifier = Modifier
.size(blockWidth * cell.blocks, rowHeight)
.background(color = cell.bgColor)
.border(width = 2.dp, color = Color.Black)
,
) {
Text(
modifier = Modifier
.align(Alignment.Center)
,
text = cell.text,
color = cell.textColor,
)
}
}
I guess the default configuration of the grid puts the empty block after Subject 3 on Monday into Tuesday because it tries to match the width of the longest row first.
However, I would like to force a behaviour such that the row index is determined only from the index of the item in parameter cells: List<Cell> and doesn't depend on the widths of the rendered items.
Maybe another kind of composable would help me here but I need the "unified" scrolling functionality (rows DON'T scroll independently) so something like a Column with verticalScroll modifier that has Rows with horizontalScroll modifiers is out of the question.
A LazyHorizontalStaggeredGrid is not suitable for your use case, as it will not strictly distribute the items round-robin one item per column. Instead, it will distribute it so that the width of the LazyHorizontalStaggeredGrid grows evenly. So as soon as you have a wider item, it will start to break the order of elements you have declared in your defaultCells List.
I think solving this with any lazy layout won't be possible, but in your use case of displaying a calendar, I would argue that the UI complexity is not as high that it would enforce the use of a lazy list anyway.
So what you can try is to simply build your UI using regular Row and Column like this:
@Composable
private fun TimeTable(
cells: List<List<Cell>>,
rows: Int = 6,
) {
Column(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.verticalScroll(rememberScrollState())
) {
cells.forEach { dayCells ->
Row {
dayCells.forEach { dayEntry ->
TableCell(dayEntry)
}
}
}
}
}
private val defaultCells: List<List<Cell>>
get() = listOf(
listOf(
header(""),
header("08:00"),
header("09:00"),
header("10:00"),
header("11:00"),
header("12:00"),
header("13:00"),
header("14:00"),
header("15:00"),
header("16:00"),
),
listOf(
header("MO"),
empty(), // 08:00
empty(),
subject("Subject 1"),
subject("Subject 1", color = SubjectColor.BLUE), // 10:00
subject("Subject 3", blocks = 3), // 12:00
empty(), // 14:00
),
listOf(
header("TU"),
empty(), // 08:00
empty(),
subject("Subject 2"), // 10:00
subject("Subject 4"), // 12:00
subject("Subject 2", color = SubjectColor.BLUE), // 14:00
subject("Subject 7"), // 16:00
),
listOf(
header("WE"),
empty(), // 08:00
subject("Subject 1"), // 10:00
subject("Subject 5"), // 12:00
subject("Subject 3", color = SubjectColor.BLUE), // 14:00
subject("Subject 7", color = SubjectColor.BLUE), // 16:00
),
listOf(
header("TH"),
empty(), // 08:00
empty(),
subject("Subject 2"), // 10:00
empty(), // 12:00
subject("Subject 4", blocks = 3, color = SubjectColor.BLUE), // 14:00
empty(), // 16:00
),
listOf(
header("FR"),
empty(), // 08:00
empty(),
empty(),
empty(), // 10:00
empty(), // 12:00
subject("Subject 6"), // 14:00
subject("Subject 6", color = SubjectColor.BLUE), // 16:00
),
)
I also needed to rearrange your data structure, it now stores a List of Lists of Cell.
Output: