0

I'm new to both Kotlin and Jetpack Compose, and am trying to implement a custom composable which takes in a list of cells and a number of columns, and lays it out in a table format. Each cell in the table can hold any content. Essentially a grid of fixed column count which has borders for all its cells.

Ultimately I would like to use my table like so:

Table(2) {
    SomeContent(onclick = { /**/ })
    SomeOtherContent()
    Text("Some text")
    //etc...
}

Beyond laying out the content in a table format, there are two additional things I want to accomplish:

  1. I don't want to have to manage border logic in the cells themselves. The table should add borders to each cell as appropriate. I would ideally be able to accomplish this by wrapping each child in a box with the needed borders.
  2. If I pass in some number of cells such that the last row of the table is not completely filled, I would like my table function to add additional empty cells (so that borders still show for cells without content). To do this I need to know the number of children contained in the content, as well as be able to add new children.

A Note: I don't think this matters for my issue, but I'm using Compose Multiplatform to develop for desktop platforms, not mobile. From what I understand, most things carry over directly, however Android specific APIs are not available to me.

So far, I have come up with this code, which gets me close to what I want:

@Composable
fun Table(
    columnCount: Int,
    cellContent: @Composable () -> Unit //might not contain correct number of cells to fill the table
) {
    Layout(
        content = cellContent
    ) { measurables, constraints ->
        //layout logic to produce a table
    }
}

But unfortunately, while this does produce a table, it fails at my other goals, because I can't figure out how to access the children passed into my composable as a list of elements that can be modified. It seems possible to get a list of something, because the MeasureScope inside the Layout lambda provides a list of measurables, but I'm unsure what I would need to do to modify said list either. I feel like I'm missing something very simple and it's driving me up the wall.

Per this answer: https://stackoverflow.com/a/69649279 I tried passing in a list of non composable objects and a content builder function like so:

@Composable
fun<T> Table(
    columnCount: Int,
    cellData: List<T>,
    cellContent: @Composable (item: T) -> Unit
)

But the issue with that is that cell content can be anything at all, and it seems silly that the Table would need to care about how any particular cell is constructed when all it should be doing is measuring its children and arranging them. What if I want some of my cells to have a onClick callback but not others? What if I have dozens of different types of cells all requiring different arguments?

My question: How can I access and modify the content passed into my table as a list, so that I can modify each child before the layout actually occurs (or during the MeasureScope provided by the Layout function), as well as add additional child content as needed? If this is impossible, what approach should I take instead?

1 Answers1

0

I think you have given so much responsibility to the Table composable for rendering, I mean it has to calculate some content ...etc and then it handles/calculates children dimensions. To answer your points

  1. Create modifiers, let the table apply the appropriate border modifier, and here is an example:
fun Modifier.bottomBorder(
    color: Color = Color.Black,
    strokeWidth: Dp = 1.dp,
) = this.drawWithCache {
    onDrawWithContent {
        drawContent()
        drawLine(
            color,
            Offset(0f, size.height - strokeWidth.value),
            Offset(size.width, size.height - strokeWidth.value),
            strokeWidth.value
        )
    }
}
  1. Prioritize header columns, thier count and index. When data comes in, it better be in order and empty cells padded with null or empty strings etc. But its overkill for compose to handle logic of adding data, so its best to make a stateful list/map/nestedList, that the table can draw on change.

For a cell to handle custom commands (like onClick) you have to give the rendering power to the cell, or else your table will do weird calculations on what has been clicked. I use decompose to handle state and logic, and leave compose to receive the state of my map, each object with a unique id. Each cell will call the exposed component (from decompose) with its id. For example:

data class SimpleTableItem(
    val dataUUID: String,
    val text: String = "",
    val isSelected: Boolean = false,
    val onItemClicked: ((String) -> Unit)? = null,
    val onItemDoubleClicked: ((String) -> Unit)? = null,
    val onItemRightClicked: ((String) -> Unit)? = null,
    val composable: @Composable (() -> Unit)? = null
)

@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
@Composable
fun RowScope.TableCell(
    dataUUID: String,
    text: String,
    weight: Float,
    onItemClicked: ((String) -> Unit)? = null,
    onItemRightClicked: ((String) -> Unit)? = null,
    onItemDoubleClicked: ((String) -> Unit)? = null,
    composable: @Composable (() -> Unit)? = null
) {
    val interactionSource = remember { MutableInteractionSource() }
    if (composable != null) {
        Box(
            modifier = Modifier
                .onClick(
                    interactionSource = interactionSource,
                    matcher = PointerMatcher.mouse(PointerButton.Secondary),
                ) {
                    onItemRightClicked?.invoke(dataUUID)
                }
                .onClick(
                    interactionSource = interactionSource,
                    onDoubleClick = {
                        onItemDoubleClicked?.invoke(dataUUID)
                    }
                ) {}
                .onPointerEvent(PointerEventType.Press) { onItemClicked?.invoke(dataUUID) }
                .border(.25.dp, Color(0xFFF7FAFB))
                .weight(weight)
                .padding(8.dp)
        ) {
            composable()
        }
    } else {
        Text(
            text = text,
            Modifier
                .onClick(
                    interactionSource = interactionSource,
                    matcher = PointerMatcher.mouse(PointerButton.Secondary),
                ) {
                    onItemRightClicked?.invoke(dataUUID)
                }
                .onClick(
                    interactionSource = interactionSource,
                    onDoubleClick = {
                        onItemDoubleClicked?.invoke(dataUUID)
                    }
                ) {}
                .onPointerEvent(PointerEventType.Press) { onItemClicked?.invoke(dataUUID) }
                .border(.25.dp, Color(0xFFF7FAFB))
                .weight(weight)
                .padding(8.dp)
        )
    }

}

Compose is weird but not impossible. Cheers